mirror of
https://github.com/taogaetz/chefbible.git
synced 2025-12-06 19:50:12 -05:00
872 lines
26 KiB
Svelte
872 lines
26 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import type { PageData } from './$types';
|
|
import { renderMarkdownToHTML } from '$lib/utils/markdown';
|
|
import {
|
|
compressImage,
|
|
formatFileSize,
|
|
isImageFile,
|
|
getCompressionSettings
|
|
} from '$lib/utils/imageCompression';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
let pending = $state(false);
|
|
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;
|
|
originalSize: number;
|
|
compressedSize: number;
|
|
compressionRatio: number;
|
|
isCompressed: boolean;
|
|
} | null>(null);
|
|
let showMarkdownHelp = $state(false);
|
|
let showIngredientHelp = $state(false);
|
|
|
|
// Initialize form with existing data
|
|
$effect(() => {
|
|
if (data.recipe) {
|
|
// Convert recipe ingredients back to text format
|
|
const ingredientLines = data.recipe.ingredients.map((ri) => {
|
|
let line = '';
|
|
if (ri.quantity) line += ri.quantity;
|
|
if (ri.unit) line += ' ' + ri.unit;
|
|
if (ri.ingredient.name) line += ' ' + ri.ingredient.name;
|
|
if (ri.prep) line += ', ' + ri.prep;
|
|
return line;
|
|
});
|
|
ingredientsText = ingredientLines.join('\n');
|
|
|
|
// Initialize description and instructions
|
|
descriptionText = data.recipe.description || '';
|
|
instructionsText = data.recipe.instructions || '';
|
|
}
|
|
});
|
|
|
|
// Photo handling functions
|
|
async function handlePhotoChange(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
|
|
if (file) {
|
|
// Validate file type
|
|
if (!isImageFile(file)) {
|
|
lastError = 'Please select a valid image file';
|
|
input.value = '';
|
|
return;
|
|
}
|
|
|
|
// Validate file size (50MB limit for original files)
|
|
if (file.size > 50 * 1024 * 1024) {
|
|
lastError = 'Photo must be less than 50MB';
|
|
input.value = '';
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
function removePhoto() {
|
|
if (selectedPhoto) {
|
|
URL.revokeObjectURL(selectedPhoto.preview);
|
|
selectedPhoto = null;
|
|
}
|
|
// Reset the file input
|
|
const fileInput = document.querySelector('input[name="photo"]') as HTMLInputElement;
|
|
if (fileInput) {
|
|
fileInput.value = '';
|
|
}
|
|
}
|
|
|
|
// Parsing functions for live preview
|
|
function parseMixedNumber(input: string): number | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
// e.g. 1 1/2, 3/4, 2.5
|
|
const parts = trimmed.split(/\s+/);
|
|
let total = 0;
|
|
for (const part of parts) {
|
|
if (/^\d+\/\d+$/.test(part)) {
|
|
const [num, den] = part.split('/').map(Number);
|
|
if (den === 0) return null;
|
|
total += num / den;
|
|
} else if (/^\d+(?:\.\d+)?$/.test(part)) {
|
|
total += Number(part);
|
|
} else {
|
|
// unexpected token
|
|
return null;
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
const unitAliases: Record<string, { canonical: string; system: 'volume' | 'mass' | 'count' }> = {
|
|
// volume
|
|
tsp: { canonical: 'ml', system: 'volume' },
|
|
'tsp.': { canonical: 'ml', system: 'volume' },
|
|
teaspoon: { canonical: 'ml', system: 'volume' },
|
|
teaspoons: { canonical: 'ml', system: 'volume' },
|
|
tbsp: { canonical: 'ml', system: 'volume' },
|
|
'tbsp.': { canonical: 'ml', system: 'volume' },
|
|
tablespoon: { canonical: 'ml', system: 'volume' },
|
|
tablespoons: { canonical: 'ml', system: 'volume' },
|
|
cup: { canonical: 'ml', system: 'volume' },
|
|
cups: { canonical: 'ml', system: 'volume' },
|
|
ml: { canonical: 'ml', system: 'volume' },
|
|
milliliter: { canonical: 'ml', system: 'volume' },
|
|
milliliters: { canonical: 'ml', system: 'volume' },
|
|
l: { canonical: 'ml', system: 'volume' },
|
|
liter: { canonical: 'ml', system: 'volume' },
|
|
liters: { canonical: 'ml', system: 'volume' },
|
|
pt: { canonical: 'ml', system: 'volume' },
|
|
qt: { canonical: 'ml', system: 'volume' },
|
|
gal: { canonical: 'ml', system: 'volume' },
|
|
floz: { canonical: 'ml', system: 'volume' },
|
|
'fl-oz': { canonical: 'ml', system: 'volume' },
|
|
'fl oz': { canonical: 'ml', system: 'volume' },
|
|
// mass
|
|
g: { canonical: 'g', system: 'mass' },
|
|
gram: { canonical: 'g', system: 'mass' },
|
|
grams: { canonical: 'g', system: 'mass' },
|
|
kg: { canonical: 'g', system: 'mass' },
|
|
kilogram: { canonical: 'g', system: 'mass' },
|
|
kilograms: { canonical: 'g', system: 'mass' },
|
|
oz: { canonical: 'g', system: 'mass' },
|
|
ounce: { canonical: 'g', system: 'mass' },
|
|
ounces: { canonical: 'g', system: 'mass' },
|
|
lb: { canonical: 'g', system: 'mass' },
|
|
lbs: { canonical: 'g', system: 'mass' },
|
|
pound: { canonical: 'g', system: 'mass' },
|
|
pounds: { canonical: 'g', system: 'mass' },
|
|
// count / generic pieces stay as-is
|
|
clove: { canonical: 'clove', system: 'count' },
|
|
cloves: { canonical: 'clove', 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(
|
|
line: string
|
|
): { name: string; quantity: number | null; unit: string | null; prep: string | null } | null {
|
|
const raw = line.trim();
|
|
if (!raw) return null;
|
|
|
|
// Split the line into parts
|
|
const parts = raw.split(/\s+/);
|
|
|
|
// If only one part, it's just a name
|
|
if (parts.length === 1) {
|
|
return { name: parts[0], quantity: null, unit: null, prep: null };
|
|
}
|
|
|
|
// Check if first part is a quantity
|
|
const firstPart = parts[0];
|
|
const quantity = parseMixedNumber(firstPart);
|
|
|
|
if (quantity !== null) {
|
|
// We have a quantity, check if second part is a unit
|
|
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 <= 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]) {
|
|
const unit = unitLower;
|
|
const name = parts.slice(2).join(' ');
|
|
// Check if there's prep info after a comma
|
|
const commaIndex = name.indexOf(',');
|
|
let finalName = name;
|
|
let prep = null;
|
|
if (commaIndex !== -1) {
|
|
finalName = name.substring(0, commaIndex);
|
|
prep = name.substring(commaIndex + 1).trim();
|
|
}
|
|
|
|
return {
|
|
name: finalName.trim(),
|
|
quantity,
|
|
unit,
|
|
prep
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// We have a quantity but no clear unit, check if it's a no-space unit like "3g"
|
|
if (firstPart.length > 1) {
|
|
const match = firstPart.match(/^(\d+(?:[\s-]+\d+\/\d+|\/\d+|\.\d+)?)([a-zA-Z]+)$/);
|
|
if (match) {
|
|
const [, qtyStr, unit] = match;
|
|
const qtyNum = parseMixedNumber(qtyStr);
|
|
if (qtyNum !== null) {
|
|
const unitLower = unit.toLowerCase();
|
|
// Only treat as unit if it's in our known unit aliases
|
|
if (unitAliases[unitLower]) {
|
|
const name = parts.slice(1).join(' ');
|
|
const commaIndex = name.indexOf(',');
|
|
let finalName = name;
|
|
let prep = null;
|
|
if (commaIndex !== -1) {
|
|
finalName = name.substring(0, commaIndex);
|
|
prep = name.substring(commaIndex + 1).trim();
|
|
}
|
|
|
|
return {
|
|
name: finalName.trim(),
|
|
quantity: qtyNum,
|
|
unit: unitLower,
|
|
prep
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quantity but no unit found
|
|
const name = parts.slice(1).join(' ');
|
|
const commaIndex = name.indexOf(',');
|
|
let finalName = name;
|
|
let prep = null;
|
|
if (commaIndex !== -1) {
|
|
finalName = name.substring(0, commaIndex);
|
|
prep = name.substring(commaIndex + 1).trim();
|
|
}
|
|
|
|
return {
|
|
name: finalName.trim(),
|
|
quantity,
|
|
unit: null,
|
|
prep
|
|
};
|
|
}
|
|
|
|
// No quantity found in first part, check if it's a no-space unit like "1g"
|
|
if (firstPart.length > 1) {
|
|
const match = firstPart.match(/^(\d+(?:[\s-]+\d+\/\d+|\/\d+|\.\d+)?)([a-zA-Z]+)$/);
|
|
if (match) {
|
|
const [, qtyStr, unit] = match;
|
|
const qtyNum = parseMixedNumber(qtyStr);
|
|
if (qtyNum !== null) {
|
|
const unitLower = unit.toLowerCase();
|
|
// Only treat as unit if it's in our known unit aliases
|
|
if (unitAliases[unitLower]) {
|
|
const name = parts.slice(1).join(' ');
|
|
const commaIndex = name.indexOf(',');
|
|
let finalName = name;
|
|
let prep = null;
|
|
if (commaIndex !== -1) {
|
|
finalName = name.substring(0, commaIndex);
|
|
prep = name.substring(commaIndex + 1).trim();
|
|
}
|
|
|
|
return {
|
|
name: finalName.trim(),
|
|
quantity: qtyNum,
|
|
unit: unitLower,
|
|
prep
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// No quantity found, treat as name only
|
|
const name = parts.join(' ');
|
|
const commaIndex = name.indexOf(',');
|
|
let finalName = name;
|
|
let prep = null;
|
|
if (commaIndex !== -1) {
|
|
finalName = name.substring(0, commaIndex);
|
|
prep = name.substring(commaIndex + 1).trim();
|
|
}
|
|
|
|
return {
|
|
name: finalName.trim(),
|
|
quantity: null,
|
|
unit: null,
|
|
prep
|
|
};
|
|
}
|
|
|
|
async function onsubmit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
const form = event.target as HTMLFormElement;
|
|
if (!form) return;
|
|
|
|
pending = true;
|
|
lastError = null;
|
|
lastSuccess = null;
|
|
|
|
try {
|
|
// Parse ingredients on client side
|
|
const ingredientsRaw = ingredientsText.trim();
|
|
const parsedIngredients: Array<{
|
|
name: string;
|
|
quantity: number | null;
|
|
unit: string | null;
|
|
prep: string | null;
|
|
}> = [];
|
|
|
|
if (ingredientsRaw) {
|
|
const lines = ingredientsRaw.split('\n').filter((line) => line.trim());
|
|
for (const line of lines) {
|
|
const parsed = parseIngredientLine(line.trim());
|
|
if (parsed) {
|
|
parsedIngredients.push(parsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create FormData with parsed ingredients
|
|
const formData = new FormData(form);
|
|
|
|
// Remove the raw ingredients textarea and update with state values
|
|
formData.delete('ingredients');
|
|
formData.set('description', descriptionText);
|
|
formData.set('instructions', instructionsText);
|
|
|
|
// Remove the original photo from FormData (if it exists)
|
|
formData.delete('photo');
|
|
|
|
// Add parsed ingredients as JSON
|
|
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
|
|
|
|
// Add compressed photo if selected
|
|
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);
|
|
}
|
|
|
|
// Submit to server
|
|
const response = await fetch(`?/update`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Show success message
|
|
lastSuccess = 'Recipe updated successfully!';
|
|
formSubmitted = true;
|
|
|
|
// Redirect to the updated recipe after a short delay
|
|
const result = await response.json();
|
|
if (result.location) {
|
|
setTimeout(() => {
|
|
window.location.href = result.location;
|
|
}, 1500);
|
|
}
|
|
} else {
|
|
const errorData = await response.json();
|
|
lastError = errorData.message || 'Failed to update recipe';
|
|
}
|
|
} catch (error) {
|
|
lastError = 'An error occurred while updating the recipe';
|
|
console.error('Error updating recipe:', error);
|
|
} finally {
|
|
pending = false;
|
|
}
|
|
}
|
|
|
|
async function deleteRecipe() {
|
|
if (!data.recipe?.id) return;
|
|
|
|
pending = true;
|
|
lastError = null;
|
|
lastSuccess = null;
|
|
|
|
try {
|
|
const response = await fetch(`/recipe/${data.recipe.id}/edit`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
lastSuccess = 'Recipe deleted successfully!';
|
|
setTimeout(() => {
|
|
window.location.href = '/';
|
|
}, 1500);
|
|
} else {
|
|
const errorData = await response.json();
|
|
lastError = errorData.message || 'Failed to delete recipe';
|
|
}
|
|
} catch (error) {
|
|
lastError = 'An error occurred while deleting the recipe';
|
|
console.error('Error deleting recipe:', error);
|
|
} finally {
|
|
pending = false;
|
|
showDeleteConfirm = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="mx-auto my-4 w-full max-w-2xl px-3 sm:my-6 sm:px-6 lg:max-w-4xl">
|
|
<h1 class="mb-4 text-xl font-extrabold sm:mb-5 sm:text-2xl">Edit Recipe: {data.recipe?.name}</h1>
|
|
|
|
<form
|
|
method="POST"
|
|
action="?/update"
|
|
{onsubmit}
|
|
class="card relative grid gap-4 overflow-hidden border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
|
|
>
|
|
{#if pending}
|
|
<div
|
|
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-base-100/80 backdrop-blur-sm"
|
|
>
|
|
<div class="text-center">
|
|
<span class="loading loading-lg loading-spinner text-primary"></span>
|
|
<p class="mt-2 text-base-content/70">Updating your recipe...</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
<label class="form-control sm:col-span-2 lg:col-span-1">
|
|
<span class="label"><span class="label-text font-bold">Name</span></span>
|
|
<input
|
|
name="name"
|
|
required
|
|
autocomplete="off"
|
|
inputmode="text"
|
|
class="input-bordered input w-full"
|
|
value={data.recipe?.name || ''}
|
|
/>
|
|
</label>
|
|
|
|
<label class="form-control min-w-0">
|
|
<span class="label"><span class="label-text font-bold">Time</span></span>
|
|
<select name="time" class="select-bordered select w-full min-w-0">
|
|
<option value="Quick" selected={data.recipe?.time === 'Quick'}>Quick</option>
|
|
<option value="Medium" selected={data.recipe?.time === 'Medium'}>Medium</option>
|
|
<option value="Long" selected={data.recipe?.time === 'Long'}>Long</option>
|
|
</select>
|
|
</label>
|
|
|
|
<label class="form-control min-w-0">
|
|
<span class="label"><span class="label-text font-bold">Station</span></span>
|
|
<select name="station" class="select-bordered select w-full min-w-0">
|
|
<option value="Garde Manger" selected={data.recipe?.station === 'Garde Manger'}
|
|
>Garde Manger</option
|
|
>
|
|
<option value="Pans" selected={data.recipe?.station === 'Pans'}>Pans</option>
|
|
<option value="Grill" selected={data.recipe?.station === 'Grill'}>Grill</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<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>
|
|
<input
|
|
type="file"
|
|
name="photo"
|
|
accept="image/*"
|
|
class="file-input-bordered file-input w-full min-w-0 file-input-primary"
|
|
onchange={handlePhotoChange}
|
|
/>
|
|
</label>
|
|
|
|
{#if selectedPhoto}
|
|
<div class="flex items-center gap-3 rounded-lg bg-base-200 p-3">
|
|
<div class="avatar">
|
|
<div class="w-20 rounded-lg">
|
|
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
|
|
</div>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm font-medium">{selectedPhoto.name}</p>
|
|
<div class="text-xs text-base-content/60">
|
|
<p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
|
{#if selectedPhoto.isCompressed}
|
|
<p class="text-success">
|
|
{((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>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if data.recipe?.photoUrl && !selectedPhoto}
|
|
<div class="flex items-center gap-3 rounded-lg bg-base-200 p-3">
|
|
<div class="avatar">
|
|
<div class="w-20 rounded-lg">
|
|
<img src={data.recipe.photoUrl} alt="Current recipe photo" class="object-cover" />
|
|
</div>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<label class="form-control min-w-0">
|
|
<span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span
|
|
>
|
|
|
|
<textarea
|
|
name="ingredients"
|
|
rows="6"
|
|
placeholder="2 cups flour, sifted
|
|
3g salt
|
|
500ml water"
|
|
class="textarea-bordered textarea w-full min-w-0"
|
|
bind:value={ingredientsText}
|
|
></textarea>
|
|
|
|
<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>
|
|
{#each ingredientsText.split('\n').filter((line) => line.trim()) as line, i}
|
|
{#if line.trim()}
|
|
{@const parsed = parseIngredientLine(line.trim())}
|
|
{#if parsed}
|
|
<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="flex flex-wrap gap-1 overflow-hidden text-sm">
|
|
{#if parsed.quantity}
|
|
<span class="badge badge-sm whitespace-nowrap badge-primary"
|
|
>Qty: {parsed.quantity}</span
|
|
>
|
|
{/if}
|
|
{#if parsed.unit}
|
|
<span class="badge badge-sm whitespace-nowrap badge-secondary"
|
|
>Unit: {parsed.unit}</span
|
|
>
|
|
{/if}
|
|
<span class="badge max-w-32 truncate badge-sm whitespace-nowrap badge-accent"
|
|
>Name: {parsed.name}</span
|
|
>
|
|
{#if parsed.prep}
|
|
<span class="badge max-w-32 truncate badge-outline badge-sm whitespace-nowrap"
|
|
>Prep: {parsed.prep}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</label>
|
|
|
|
<label class="form-control min-w-0">
|
|
<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 min-w-0"
|
|
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 min-w-0">
|
|
<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 min-w-0"
|
|
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">
|
|
{#if data.authenticated}
|
|
<!-- Delete Recipe Button -->
|
|
<button
|
|
type="button"
|
|
class="btn order-1 w-full btn-outline btn-error sm:order-1 sm:w-auto"
|
|
onclick={() => (showDeleteConfirm = true)}
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M3 6h18M19 6v14a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
Delete Recipe
|
|
</button>
|
|
{/if}
|
|
|
|
<a class="btn order-2 w-full sm:order-1 sm:w-auto" href="/recipe/{data.recipe?.id}">Cancel</a>
|
|
<button
|
|
class="btn order-1 w-full font-extrabold btn-primary sm:order-2 sm:w-auto"
|
|
type="submit"
|
|
name="/update"
|
|
disabled={pending}
|
|
>
|
|
{#if pending}
|
|
<span class="loading loading-sm loading-spinner"></span>
|
|
Updating Recipe…
|
|
{:else}
|
|
Update Recipe
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if lastError}
|
|
<p class="text-error">{lastError}</p>
|
|
{/if}
|
|
{#if lastSuccess}
|
|
<div class="mt-3 alert alert-success">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-6 w-6 shrink-0 stroke-current"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<span>{lastSuccess}</span>
|
|
</div>
|
|
{/if}
|
|
</form>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
{#if showDeleteConfirm}
|
|
<div class="modal-open modal">
|
|
<div class="modal-box">
|
|
<h3 class="text-lg font-bold text-error">⚠️ Delete Recipe</h3>
|
|
<p class="py-4">
|
|
Are you sure you want to delete <strong>"{data.recipe?.name}"</strong>?
|
|
<br /><br />
|
|
<span class="font-semibold text-error">This action is irreversible!</span>
|
|
<br /><br />
|
|
The recipe and all its data will be permanently removed.
|
|
</p>
|
|
<div class="modal-action">
|
|
<button class="btn" onclick={() => (showDeleteConfirm = false)}> Cancel </button>
|
|
<button class="btn btn-error" onclick={deleteRecipe}>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M3 6h18M19 6v14a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
Delete Forever
|
|
</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}
|
|
|
|
<!-- 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>
|
|
/* Utilities provided by Tailwind/daisyUI; no component-scoped CSS needed */
|
|
</style>
|