chefbible/src/routes/recipe/[id]/edit/+page.svelte
2025-09-08 14:23:31 -04:00

798 lines
24 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown';
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; size: number } | 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
function handlePhotoChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
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
if (!file.type.startsWith('image/')) {
lastError = 'Please select a valid image file';
input.value = '';
return;
}
// Create preview URL
const preview = URL.createObjectURL(file);
selectedPhoto = {
file,
preview,
name: file.name,
size: file.size
};
lastError = 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);
// Add parsed ingredients as JSON
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
// Add photo if selected
if (selectedPhoto) {
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 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">
<span class="label"><span class="label-text font-bold">Time</span></span>
<select name="time" class="select-bordered select w-full">
<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">
<span class="label"><span class="label-text font-bold">Station</span></span>
<select name="station" class="select-bordered select w-full">
<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">
<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 file-input-primary"
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>
{#if selectedPhoto}
<div class="flex items-center gap-4 rounded-lg bg-base-200 p-4">
<div class="avatar">
<div class="w-24 rounded-lg">
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
</div>
</div>
<div class="flex-1">
<p class="text-sm font-medium">{selectedPhoto.name}</p>
<p class="text-xs text-base-content/60">
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB
</p>
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
Remove Photo
</button>
</div>
</div>
{/if}
{#if data.recipe?.photoUrl && !selectedPhoto}
<div class="flex items-center gap-4 rounded-lg bg-base-200 p-4">
<div class="avatar">
<div class="w-24 rounded-lg">
<img src={data.recipe.photoUrl} alt="Current recipe photo" class="object-cover" />
</div>
</div>
<div class="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">
<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"
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-2 text-sm">
{#if parsed.quantity}
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
{/if}
{#if parsed.unit}
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
{/if}
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
{#if parsed.prep}
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
{/if}
</div>
</div>
{/if}
{/if}
{/each}
</div>
{/if}
</label>
<label class="form-control">
<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>
<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">
{#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>