mirror of
https://github.com/taogaetz/chefbible.git
synced 2025-12-06 11:47:24 -05:00
Compare commits
3 Commits
3d043d9ade
...
ec24b39d1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec24b39d1a | ||
|
|
f46064452a | ||
|
|
f8c205e9d3 |
@ -0,0 +1,20 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Recipe" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"instructions" TEXT,
|
||||||
|
"photoUrl" TEXT,
|
||||||
|
"time" TEXT NOT NULL DEFAULT 'Medium',
|
||||||
|
"station" TEXT NOT NULL DEFAULT 'Pans',
|
||||||
|
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Recipe" ("createdAt", "description", "id", "instructions", "name", "photoUrl", "station", "time", "updatedAt") SELECT "createdAt", "description", "id", "instructions", "name", "photoUrl", "station", "time", "updatedAt" FROM "Recipe";
|
||||||
|
DROP TABLE "Recipe";
|
||||||
|
ALTER TABLE "new_Recipe" RENAME TO "Recipe";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@ -25,6 +25,7 @@ model Recipe {
|
|||||||
photoUrl String?
|
photoUrl String?
|
||||||
time String @default("Medium") // Quick, Medium, Long
|
time String @default("Medium") // Quick, Medium, Long
|
||||||
station String @default("Pans") // Garde Manger, Pans, Grill
|
station String @default("Pans") // Garde Manger, Pans, Grill
|
||||||
|
hidden Boolean @default(false) // Hidden from non-chefs
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
200
src/lib/utils/imageCompression.ts
Normal file
200
src/lib/utils/imageCompression.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Client-side image compression utility
|
||||||
|
* Compresses images to reduce file size while maintaining quality
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CompressionOptions {
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
quality?: number;
|
||||||
|
maxSizeKB?: number;
|
||||||
|
format?: 'image/jpeg' | 'image/png' | 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompressionResult {
|
||||||
|
file: File;
|
||||||
|
originalSize: number;
|
||||||
|
compressedSize: number;
|
||||||
|
compressionRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress an image file using HTML5 Canvas
|
||||||
|
*/
|
||||||
|
export async function compressImage(
|
||||||
|
file: File,
|
||||||
|
options: CompressionOptions = {}
|
||||||
|
): Promise<CompressionResult> {
|
||||||
|
const {
|
||||||
|
maxWidth = 1920,
|
||||||
|
maxHeight = 1080,
|
||||||
|
quality = 0.8,
|
||||||
|
maxSizeKB = 500, // Target 500KB to stay well under SvelteKit's 512KB limit
|
||||||
|
format = 'image/jpeg'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
// Calculate new dimensions while maintaining aspect ratio
|
||||||
|
let { width, height } = img;
|
||||||
|
|
||||||
|
if (width > maxWidth || height > maxHeight) {
|
||||||
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||||
|
width = Math.floor(width * ratio);
|
||||||
|
height = Math.floor(height * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set canvas dimensions
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Draw and compress the image
|
||||||
|
ctx?.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Try different quality levels and dimensions if file is still too large
|
||||||
|
const tryCompress = (currentQuality: number, currentWidth: number, currentHeight: number): void => {
|
||||||
|
// Update canvas dimensions
|
||||||
|
canvas.width = currentWidth;
|
||||||
|
canvas.height = currentHeight;
|
||||||
|
|
||||||
|
// Redraw with new dimensions
|
||||||
|
ctx?.drawImage(img, 0, 0, currentWidth, currentHeight);
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('Failed to compress image'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeKB = blob.size / 1024;
|
||||||
|
|
||||||
|
console.log(`Compression attempt: ${currentWidth}x${currentHeight}, quality: ${currentQuality}, size: ${sizeKB.toFixed(1)}KB, target: ${maxSizeKB}KB`);
|
||||||
|
|
||||||
|
// If still too large, try more aggressive compression
|
||||||
|
if (sizeKB > maxSizeKB) {
|
||||||
|
// First try reducing quality more aggressively
|
||||||
|
if (currentQuality > 0.1) {
|
||||||
|
tryCompress(Math.max(0.1, currentQuality - 0.15), currentWidth, currentHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Then try reducing dimensions
|
||||||
|
else if (currentWidth > 400 && currentHeight > 300) {
|
||||||
|
const newWidth = Math.max(400, Math.floor(currentWidth * 0.8));
|
||||||
|
const newHeight = Math.max(300, Math.floor(currentHeight * 0.8));
|
||||||
|
tryCompress(0.1, newWidth, newHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If we've exhausted all compression options and still too large, reject
|
||||||
|
else {
|
||||||
|
console.error(`Failed to compress image below ${maxSizeKB}KB. Final size: ${sizeKB.toFixed(1)}KB`);
|
||||||
|
reject(new Error(`Unable to compress image below ${maxSizeKB}KB. Please try a different image.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new file with compressed data
|
||||||
|
const compressedFile = new File([blob], file.name, {
|
||||||
|
type: format,
|
||||||
|
lastModified: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Compression successful: ${sizeKB.toFixed(1)}KB (${((blob.size / file.size) * 100).toFixed(1)}% of original)`);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
file: compressedFile,
|
||||||
|
originalSize: file.size,
|
||||||
|
compressedSize: blob.size,
|
||||||
|
compressionRatio: blob.size / file.size
|
||||||
|
});
|
||||||
|
},
|
||||||
|
format,
|
||||||
|
currentQuality
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
tryCompress(quality, width, height);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load the image
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size for display
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is an image
|
||||||
|
*/
|
||||||
|
export function isImageFile(file: File): boolean {
|
||||||
|
return file.type.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get optimal compression settings based on file size
|
||||||
|
*/
|
||||||
|
export function getCompressionSettings(file: File): CompressionOptions {
|
||||||
|
const sizeMB = file.size / (1024 * 1024);
|
||||||
|
|
||||||
|
let settings: CompressionOptions;
|
||||||
|
|
||||||
|
if (sizeMB > 5) {
|
||||||
|
// Very large files - moderate compression (food photos need detail)
|
||||||
|
settings = {
|
||||||
|
maxWidth: 1200,
|
||||||
|
maxHeight: 900,
|
||||||
|
quality: 0.7,
|
||||||
|
maxSizeKB: 400
|
||||||
|
};
|
||||||
|
} else if (sizeMB > 2) {
|
||||||
|
// Large files - light compression
|
||||||
|
settings = {
|
||||||
|
maxWidth: 1400,
|
||||||
|
maxHeight: 1050,
|
||||||
|
quality: 0.75,
|
||||||
|
maxSizeKB: 450
|
||||||
|
};
|
||||||
|
} else if (sizeMB > 1) {
|
||||||
|
// Medium files - minimal compression
|
||||||
|
settings = {
|
||||||
|
maxWidth: 1600,
|
||||||
|
maxHeight: 1200,
|
||||||
|
quality: 0.8,
|
||||||
|
maxSizeKB: 480
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Smaller files - very light compression
|
||||||
|
settings = {
|
||||||
|
maxWidth: 1800,
|
||||||
|
maxHeight: 1350,
|
||||||
|
quality: 0.85,
|
||||||
|
maxSizeKB: 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Compression settings for ${sizeMB.toFixed(1)}MB file:`, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
@ -3,8 +3,10 @@ import type { LayoutServerLoad } from './$types';
|
|||||||
import prisma from '$lib/server/prisma';
|
import prisma from '$lib/server/prisma';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
// Get all recipes for search functionality
|
// Get recipes for search functionality
|
||||||
|
// If not authenticated, filter out hidden recipes
|
||||||
const recipes = await prisma.recipe.findMany({
|
const recipes = await prisma.recipe.findMany({
|
||||||
|
where: locals.authenticated ? {} : { hidden: false },
|
||||||
include: {
|
include: {
|
||||||
ingredients: {
|
ingredients: {
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import prisma from '$lib/server/prisma';
|
import prisma from '$lib/server/prisma';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const recipe = await prisma.recipe.findUnique({
|
const recipe = await prisma.recipe.findUnique({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
include: {
|
include: {
|
||||||
@ -18,5 +18,10 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
throw error(404, 'Recipe not found');
|
throw error(404, 'Recipe not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if recipe is hidden and user is not authenticated
|
||||||
|
if (recipe.hidden && !locals.authenticated) {
|
||||||
|
throw error(404, 'Recipe not found');
|
||||||
|
}
|
||||||
|
|
||||||
return { recipe };
|
return { recipe };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,12 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { renderMarkdownToHTML } from '$lib/utils/markdown';
|
import { renderMarkdownToHTML } from '$lib/utils/markdown';
|
||||||
|
import {
|
||||||
|
compressImage,
|
||||||
|
formatFileSize,
|
||||||
|
isImageFile,
|
||||||
|
getCompressionSettings
|
||||||
|
} from '$lib/utils/imageCompression';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@ -13,9 +19,15 @@
|
|||||||
let instructionsText = $state('');
|
let instructionsText = $state('');
|
||||||
let formSubmitted = $state(false);
|
let formSubmitted = $state(false);
|
||||||
let showDeleteConfirm = $state(false);
|
let showDeleteConfirm = $state(false);
|
||||||
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
|
let selectedPhoto = $state<{
|
||||||
null
|
file: File;
|
||||||
);
|
preview: string;
|
||||||
|
name: string;
|
||||||
|
originalSize: number;
|
||||||
|
compressedSize: number;
|
||||||
|
compressionRatio: number;
|
||||||
|
isCompressed: boolean;
|
||||||
|
} | null>(null);
|
||||||
let showMarkdownHelp = $state(false);
|
let showMarkdownHelp = $state(false);
|
||||||
let showIngredientHelp = $state(false);
|
let showIngredientHelp = $state(false);
|
||||||
|
|
||||||
@ -40,34 +52,63 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Photo handling functions
|
// Photo handling functions
|
||||||
function handlePhotoChange(event: Event) {
|
async function handlePhotoChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// Validate file size (10MB limit)
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
lastError = 'Photo must be less than 10MB';
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!isImageFile(file)) {
|
||||||
lastError = 'Please select a valid image file';
|
lastError = 'Please select a valid image file';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create preview URL
|
// Validate file size (50MB limit for original files)
|
||||||
const preview = URL.createObjectURL(file);
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
selectedPhoto = {
|
lastError = 'Photo must be less than 50MB';
|
||||||
file,
|
input.value = '';
|
||||||
preview,
|
return;
|
||||||
name: file.name,
|
}
|
||||||
size: file.size
|
|
||||||
};
|
|
||||||
lastError = null;
|
lastError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
const preview = URL.createObjectURL(file);
|
||||||
|
selectedPhoto = {
|
||||||
|
file,
|
||||||
|
preview,
|
||||||
|
name: file.name,
|
||||||
|
originalSize: file.size,
|
||||||
|
compressedSize: file.size,
|
||||||
|
compressionRatio: 1,
|
||||||
|
isCompressed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compress the image
|
||||||
|
const compressionSettings = getCompressionSettings(file);
|
||||||
|
const result = await compressImage(file, compressionSettings);
|
||||||
|
|
||||||
|
// Update with compressed file
|
||||||
|
selectedPhoto = {
|
||||||
|
file: result.file,
|
||||||
|
preview: URL.createObjectURL(result.file),
|
||||||
|
name: file.name,
|
||||||
|
originalSize: result.originalSize,
|
||||||
|
compressedSize: result.compressedSize,
|
||||||
|
compressionRatio: result.compressionRatio,
|
||||||
|
isCompressed: result.compressionRatio < 0.95
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up the original preview URL
|
||||||
|
URL.revokeObjectURL(preview);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error compressing image:', error);
|
||||||
|
lastError = 'Failed to process image. Please try a different file.';
|
||||||
|
input.value = '';
|
||||||
|
selectedPhoto = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,11 +383,21 @@
|
|||||||
formData.set('description', descriptionText);
|
formData.set('description', descriptionText);
|
||||||
formData.set('instructions', instructionsText);
|
formData.set('instructions', instructionsText);
|
||||||
|
|
||||||
|
// Remove the original photo from FormData (if it exists)
|
||||||
|
formData.delete('photo');
|
||||||
|
|
||||||
// Add parsed ingredients as JSON
|
// Add parsed ingredients as JSON
|
||||||
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
|
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
|
||||||
|
|
||||||
// Add photo if selected
|
// Add compressed photo if selected
|
||||||
if (selectedPhoto) {
|
if (selectedPhoto) {
|
||||||
|
console.log('Uploading compressed photo:', {
|
||||||
|
name: selectedPhoto.file.name,
|
||||||
|
size: selectedPhoto.file.size,
|
||||||
|
type: selectedPhoto.file.type,
|
||||||
|
originalSize: selectedPhoto.originalSize,
|
||||||
|
compressedSize: selectedPhoto.compressedSize
|
||||||
|
});
|
||||||
formData.append('photo', selectedPhoto.file);
|
formData.append('photo', selectedPhoto.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,7 +469,7 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
{onsubmit}
|
{onsubmit}
|
||||||
class="card relative grid gap-4 border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
|
class="card relative grid gap-4 overflow-hidden border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
|
||||||
>
|
>
|
||||||
{#if pending}
|
{#if pending}
|
||||||
<div
|
<div
|
||||||
@ -444,18 +495,18 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<span class="label"><span class="label-text font-bold">Time</span></span>
|
<span class="label"><span class="label-text font-bold">Time</span></span>
|
||||||
<select name="time" class="select-bordered select w-full">
|
<select name="time" class="select-bordered select w-full min-w-0">
|
||||||
<option value="Quick" selected={data.recipe?.time === 'Quick'}>Quick</option>
|
<option value="Quick" selected={data.recipe?.time === 'Quick'}>Quick</option>
|
||||||
<option value="Medium" selected={data.recipe?.time === 'Medium'}>Medium</option>
|
<option value="Medium" selected={data.recipe?.time === 'Medium'}>Medium</option>
|
||||||
<option value="Long" selected={data.recipe?.time === 'Long'}>Long</option>
|
<option value="Long" selected={data.recipe?.time === 'Long'}>Long</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<span class="label"><span class="label-text font-bold">Station</span></span>
|
<span class="label"><span class="label-text font-bold">Station</span></span>
|
||||||
<select name="station" class="select-bordered select w-full">
|
<select name="station" class="select-bordered select w-full min-w-0">
|
||||||
<option value="Garde Manger" selected={data.recipe?.station === 'Garde Manger'}
|
<option value="Garde Manger" selected={data.recipe?.station === 'Garde Manger'}
|
||||||
>Garde Manger</option
|
>Garde Manger</option
|
||||||
>
|
>
|
||||||
@ -465,56 +516,71 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-bold">Visibility</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="hidden"
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
checked={data.recipe?.hidden || false}
|
||||||
|
/>
|
||||||
|
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control min-w-0">
|
||||||
<span class="label"><span class="label-text font-bold">Photo (optional)</span></span>
|
<span class="label"><span class="label-text font-bold">Photo (optional)</span></span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
name="photo"
|
name="photo"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="file-input-bordered file-input w-full file-input-primary"
|
class="file-input-bordered file-input w-full min-w-0 file-input-primary"
|
||||||
onchange={handlePhotoChange}
|
onchange={handlePhotoChange}
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
|
||||||
<span class="label-text-alt text-sm text-base-content/60">
|
|
||||||
Supports: JPG, PNG, GIF, WebP (max 10MB)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if selectedPhoto}
|
{#if selectedPhoto}
|
||||||
<div class="flex items-center gap-4 rounded-lg bg-base-200 p-4">
|
<div class="flex items-center gap-3 rounded-lg bg-base-200 p-3">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<div class="w-24 rounded-lg">
|
<div class="w-20 rounded-lg">
|
||||||
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
|
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium">{selectedPhoto.name}</p>
|
<p class="truncate text-sm font-medium">{selectedPhoto.name}</p>
|
||||||
<p class="text-xs text-base-content/60">
|
<div class="text-xs text-base-content/60">
|
||||||
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB
|
<p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
||||||
</p>
|
{#if selectedPhoto.isCompressed}
|
||||||
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
|
<p class="text-success">
|
||||||
Remove Photo
|
{((1 - selectedPhoto.compressionRatio) * 100).toFixed(0)}% smaller
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn mt-1 btn-xs btn-error" onclick={removePhoto}>
|
||||||
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.recipe?.photoUrl && !selectedPhoto}
|
{#if data.recipe?.photoUrl && !selectedPhoto}
|
||||||
<div class="flex items-center gap-4 rounded-lg bg-base-200 p-4">
|
<div class="flex items-center gap-3 rounded-lg bg-base-200 p-3">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<div class="w-24 rounded-lg">
|
<div class="w-20 rounded-lg">
|
||||||
<img src={data.recipe.photoUrl} alt="Current recipe photo" class="object-cover" />
|
<img src={data.recipe.photoUrl} alt="Current recipe photo" class="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium">Current Photo</p>
|
<p class="text-sm font-medium">Current Photo</p>
|
||||||
<p class="text-xs text-base-content/60">Upload a new photo to replace this one</p>
|
<p class="text-xs text-base-content/60">Upload a new photo to replace this one</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span
|
<span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span
|
||||||
>
|
>
|
||||||
|
|
||||||
@ -524,7 +590,7 @@
|
|||||||
placeholder="2 cups flour, sifted
|
placeholder="2 cups flour, sifted
|
||||||
3g salt
|
3g salt
|
||||||
500ml water"
|
500ml water"
|
||||||
class="textarea-bordered textarea w-full"
|
class="textarea-bordered textarea w-full min-w-0"
|
||||||
bind:value={ingredientsText}
|
bind:value={ingredientsText}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
@ -544,16 +610,24 @@
|
|||||||
{#if parsed}
|
{#if parsed}
|
||||||
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2">
|
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2">
|
||||||
<div class="mb-1 text-xs text-base-content/50">Line {i + 1}:</div>
|
<div class="mb-1 text-xs text-base-content/50">Line {i + 1}:</div>
|
||||||
<div class="flex flex-wrap gap-2 text-sm">
|
<div class="flex flex-wrap gap-1 overflow-hidden text-sm">
|
||||||
{#if parsed.quantity}
|
{#if parsed.quantity}
|
||||||
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
|
<span class="badge badge-sm whitespace-nowrap badge-primary"
|
||||||
|
>Qty: {parsed.quantity}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if parsed.unit}
|
{#if parsed.unit}
|
||||||
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
|
<span class="badge badge-sm whitespace-nowrap badge-secondary"
|
||||||
|
>Unit: {parsed.unit}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
|
<span class="badge max-w-32 truncate badge-sm whitespace-nowrap badge-accent"
|
||||||
|
>Name: {parsed.name}</span
|
||||||
|
>
|
||||||
{#if parsed.prep}
|
{#if parsed.prep}
|
||||||
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
|
<span class="badge max-w-32 truncate badge-outline badge-sm whitespace-nowrap"
|
||||||
|
>Prep: {parsed.prep}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -564,7 +638,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label-text font-bold">Description</span>
|
<span class="label-text font-bold">Description</span>
|
||||||
<button
|
<button
|
||||||
@ -578,7 +652,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="textarea-bordered textarea w-full"
|
class="textarea-bordered textarea w-full min-w-0"
|
||||||
bind:value={descriptionText}
|
bind:value={descriptionText}
|
||||||
placeholder="Enter a description for your recipe"
|
placeholder="Enter a description for your recipe"
|
||||||
></textarea>
|
></textarea>
|
||||||
@ -593,7 +667,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label-text font-bold">Instructions</span>
|
<span class="label-text font-bold">Instructions</span>
|
||||||
<button
|
<button
|
||||||
@ -607,7 +681,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
name="instructions"
|
name="instructions"
|
||||||
rows="6"
|
rows="6"
|
||||||
class="textarea-bordered textarea w-full"
|
class="textarea-bordered textarea w-full min-w-0"
|
||||||
bind:value={instructionsText}
|
bind:value={instructionsText}
|
||||||
placeholder="Enter step-by-step instructions"
|
placeholder="Enter step-by-step instructions"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
|||||||
const instructions = (formData.get('instructions') as string | null)?.trim() || null;
|
const instructions = (formData.get('instructions') as string | null)?.trim() || null;
|
||||||
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
|
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
|
||||||
const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
|
const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
|
||||||
|
const hidden = formData.get('hidden') === 'on'; // Checkbox returns 'on' when checked
|
||||||
const photo = formData.get('photo') as File | null;
|
const photo = formData.get('photo') as File | null;
|
||||||
const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
|
const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
|
||||||
let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
|
let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
|
||||||
@ -99,7 +100,8 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
|||||||
instructions,
|
instructions,
|
||||||
photoUrl: photoUrl || existingRecipe.photoUrl, // Keep existing photo if no new one uploaded
|
photoUrl: photoUrl || existingRecipe.photoUrl, // Keep existing photo if no new one uploaded
|
||||||
time,
|
time,
|
||||||
station
|
station,
|
||||||
|
hidden
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { renderMarkdownToHTML } from '$lib/utils/markdown';
|
import { renderMarkdownToHTML } from '$lib/utils/markdown';
|
||||||
|
import {
|
||||||
|
compressImage,
|
||||||
|
formatFileSize,
|
||||||
|
isImageFile,
|
||||||
|
getCompressionSettings
|
||||||
|
} from '$lib/utils/imageCompression';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@ -11,9 +17,15 @@
|
|||||||
let descriptionText = $state('');
|
let descriptionText = $state('');
|
||||||
let instructionsText = $state('');
|
let instructionsText = $state('');
|
||||||
let formSubmitted = $state(false);
|
let formSubmitted = $state(false);
|
||||||
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
|
let selectedPhoto = $state<{
|
||||||
null
|
file: File;
|
||||||
);
|
preview: string;
|
||||||
|
name: string;
|
||||||
|
originalSize: number;
|
||||||
|
compressedSize: number;
|
||||||
|
compressionRatio: number;
|
||||||
|
isCompressed: boolean;
|
||||||
|
} | null>(null);
|
||||||
let showIngredientHelp = $state(false);
|
let showIngredientHelp = $state(false);
|
||||||
let showMarkdownHelp = $state(false);
|
let showMarkdownHelp = $state(false);
|
||||||
|
|
||||||
@ -284,11 +296,21 @@
|
|||||||
formData.set('description', descriptionText);
|
formData.set('description', descriptionText);
|
||||||
formData.set('instructions', instructionsText);
|
formData.set('instructions', instructionsText);
|
||||||
|
|
||||||
|
// Remove the original photo from FormData (if it exists)
|
||||||
|
formData.delete('photo');
|
||||||
|
|
||||||
// Add parsed ingredients as JSON
|
// Add parsed ingredients as JSON
|
||||||
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
|
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
|
||||||
|
|
||||||
// Add photo if selected
|
// Add compressed photo if selected
|
||||||
if (selectedPhoto) {
|
if (selectedPhoto) {
|
||||||
|
console.log('Uploading compressed photo:', {
|
||||||
|
name: selectedPhoto.file.name,
|
||||||
|
size: selectedPhoto.file.size,
|
||||||
|
type: selectedPhoto.file.type,
|
||||||
|
originalSize: selectedPhoto.originalSize,
|
||||||
|
compressedSize: selectedPhoto.compressedSize
|
||||||
|
});
|
||||||
formData.append('photo', selectedPhoto.file);
|
formData.append('photo', selectedPhoto.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,34 +347,63 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Photo handling functions
|
// Photo handling functions
|
||||||
function handlePhotoChange(event: Event) {
|
async function handlePhotoChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// Validate file size (10MB limit)
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
lastError = 'Photo must be less than 10MB';
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!isImageFile(file)) {
|
||||||
lastError = 'Please select a valid image file';
|
lastError = 'Please select a valid image file';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create preview URL
|
// Validate file size (50MB limit for original files)
|
||||||
const preview = URL.createObjectURL(file);
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
selectedPhoto = {
|
lastError = 'Photo must be less than 50MB';
|
||||||
file,
|
input.value = '';
|
||||||
preview,
|
return;
|
||||||
name: file.name,
|
}
|
||||||
size: file.size
|
|
||||||
};
|
|
||||||
lastError = null;
|
lastError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
const preview = URL.createObjectURL(file);
|
||||||
|
selectedPhoto = {
|
||||||
|
file,
|
||||||
|
preview,
|
||||||
|
name: file.name,
|
||||||
|
originalSize: file.size,
|
||||||
|
compressedSize: file.size,
|
||||||
|
compressionRatio: 1,
|
||||||
|
isCompressed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compress the image
|
||||||
|
const compressionSettings = getCompressionSettings(file);
|
||||||
|
const result = await compressImage(file, compressionSettings);
|
||||||
|
|
||||||
|
// Update with compressed file
|
||||||
|
selectedPhoto = {
|
||||||
|
file: result.file,
|
||||||
|
preview: URL.createObjectURL(result.file),
|
||||||
|
name: file.name,
|
||||||
|
originalSize: result.originalSize,
|
||||||
|
compressedSize: result.compressedSize,
|
||||||
|
compressionRatio: result.compressionRatio,
|
||||||
|
isCompressed: result.compressionRatio < 0.95
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up the original preview URL
|
||||||
|
URL.revokeObjectURL(preview);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error compressing image:', error);
|
||||||
|
lastError = 'Failed to process image. Please try a different file.';
|
||||||
|
input.value = '';
|
||||||
|
selectedPhoto = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,7 +428,7 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/create"
|
action="?/create"
|
||||||
{onsubmit}
|
{onsubmit}
|
||||||
class="card relative grid gap-4 border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
|
class="card relative grid gap-4 overflow-hidden border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
|
||||||
>
|
>
|
||||||
{#if pending}
|
{#if pending}
|
||||||
<div
|
<div
|
||||||
@ -397,13 +448,13 @@
|
|||||||
required
|
required
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
inputmode="text"
|
inputmode="text"
|
||||||
class="input-bordered input w-full"
|
class="input-bordered input w-full min-w-0"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label"><span class="label-text font-bold">Time</span></span>
|
<span class="label"><span class="label-text font-bold">Time</span></span>
|
||||||
<select name="time" class="select-bordered select w-full">
|
<select name="time" class="select-bordered select w-full min-w-0">
|
||||||
<option>Quick</option>
|
<option>Quick</option>
|
||||||
<option selected>Medium</option>
|
<option selected>Medium</option>
|
||||||
<option>Long</option>
|
<option>Long</option>
|
||||||
@ -412,7 +463,7 @@
|
|||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label"><span class="label-text font-bold">Station</span></span>
|
<span class="label"><span class="label-text font-bold">Station</span></span>
|
||||||
<select name="station" class="select-bordered select w-full">
|
<select name="station" class="select-bordered select w-full min-w-0">
|
||||||
<option>Garde Manger</option>
|
<option>Garde Manger</option>
|
||||||
<option selected>Pans</option>
|
<option selected>Pans</option>
|
||||||
<option>Grill</option>
|
<option>Grill</option>
|
||||||
@ -420,42 +471,52 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-bold">Visibility</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="checkbox" name="hidden" class="checkbox checkbox-primary" />
|
||||||
|
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control min-w-0">
|
||||||
<span class="label"><span class="label-text font-bold">Photo (optional)</span></span>
|
<span class="label"><span class="label-text font-bold">Photo (optional)</span></span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
name="photo"
|
name="photo"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="file-input-bordered file-input w-full file-input-primary"
|
class="file-input-bordered file-input w-full min-w-0 file-input-primary"
|
||||||
onchange={handlePhotoChange}
|
onchange={handlePhotoChange}
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
|
||||||
<span class="label-text-alt text-sm text-base-content/60">
|
|
||||||
Supports: JPG, PNG, GIF, WebP (max 10MB)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if selectedPhoto}
|
{#if selectedPhoto}
|
||||||
<div class="flex items-center gap-4 rounded-lg bg-base-200 p-4">
|
<div class="flex items-center gap-3 rounded-lg bg-base-200 p-3">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<div class="w-24 rounded-lg">
|
<div class="w-20 rounded-lg">
|
||||||
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
|
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium">{selectedPhoto.name}</p>
|
<p class="truncate text-sm font-medium">{selectedPhoto.name}</p>
|
||||||
<p class="text-xs text-base-content/60">
|
<div class="text-xs text-base-content/60">
|
||||||
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB
|
<p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
||||||
</p>
|
{#if selectedPhoto.isCompressed}
|
||||||
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
|
<p class="text-success">
|
||||||
Remove Photo
|
{((1 - selectedPhoto.compressionRatio) * 100).toFixed(0)}% smaller
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn mt-1 btn-xs btn-error" onclick={removePhoto}>
|
||||||
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<span class="label"
|
<span class="label"
|
||||||
><span class="label-text font-bold">Ingredients (one per line)</span></span
|
><span class="label-text font-bold">Ingredients (one per line)</span></span
|
||||||
>
|
>
|
||||||
@ -466,7 +527,7 @@
|
|||||||
placeholder="2 cups flour, sifted
|
placeholder="2 cups flour, sifted
|
||||||
3g salt
|
3g salt
|
||||||
500ml water"
|
500ml water"
|
||||||
class="textarea-bordered textarea w-full"
|
class="textarea-bordered textarea w-full min-w-0"
|
||||||
bind:value={ingredientsText}
|
bind:value={ingredientsText}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
@ -487,16 +548,25 @@
|
|||||||
{#if parsed}
|
{#if parsed}
|
||||||
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2">
|
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2">
|
||||||
<div class="mb-1 text-xs text-base-content/50">Line {i + 1}:</div>
|
<div class="mb-1 text-xs text-base-content/50">Line {i + 1}:</div>
|
||||||
<div class="flex flex-wrap gap-2 text-sm">
|
<div class="flex flex-wrap gap-1 overflow-hidden text-sm">
|
||||||
{#if parsed.quantity}
|
{#if parsed.quantity}
|
||||||
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
|
<span class="badge badge-sm whitespace-nowrap badge-primary"
|
||||||
|
>Qty: {parsed.quantity}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if parsed.unit}
|
{#if parsed.unit}
|
||||||
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
|
<span class="badge badge-sm whitespace-nowrap badge-secondary"
|
||||||
|
>Unit: {parsed.unit}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
|
<span class="badge max-w-32 truncate badge-sm whitespace-nowrap badge-accent"
|
||||||
|
>Name: {parsed.name}</span
|
||||||
|
>
|
||||||
{#if parsed.prep}
|
{#if parsed.prep}
|
||||||
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
|
<span
|
||||||
|
class="badge max-w-32 truncate badge-outline badge-sm whitespace-nowrap"
|
||||||
|
>Prep: {parsed.prep}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -506,7 +576,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label-text font-bold">Description</span>
|
<span class="label-text font-bold">Description</span>
|
||||||
<button
|
<button
|
||||||
@ -520,7 +590,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="textarea-bordered textarea w-full"
|
class="textarea-bordered textarea w-full min-w-0"
|
||||||
bind:value={descriptionText}
|
bind:value={descriptionText}
|
||||||
placeholder="Enter a description for your recipe"
|
placeholder="Enter a description for your recipe"
|
||||||
></textarea>
|
></textarea>
|
||||||
@ -535,7 +605,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label-text font-bold">Instructions</span>
|
<span class="label-text font-bold">Instructions</span>
|
||||||
<button
|
<button
|
||||||
@ -549,7 +619,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
name="instructions"
|
name="instructions"
|
||||||
rows="6"
|
rows="6"
|
||||||
class="textarea-bordered textarea w-full"
|
class="textarea-bordered textarea w-full min-w-0"
|
||||||
bind:value={instructionsText}
|
bind:value={instructionsText}
|
||||||
placeholder="Enter step-by-step instructions"
|
placeholder="Enter step-by-step instructions"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
const instructions = (formData.get('instructions') as string | null)?.trim() || null;
|
const instructions = (formData.get('instructions') as string | null)?.trim() || null;
|
||||||
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
|
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
|
||||||
const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
|
const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
|
||||||
|
const hidden = formData.get('hidden') === 'on'; // Checkbox returns 'on' when checked
|
||||||
const photo = formData.get('photo') as File | null;
|
const photo = formData.get('photo') as File | null;
|
||||||
const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
|
const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
|
||||||
let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
|
let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
|
||||||
@ -89,7 +90,8 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
instructions,
|
instructions,
|
||||||
photoUrl,
|
photoUrl,
|
||||||
time,
|
time,
|
||||||
station
|
station,
|
||||||
|
hidden
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user