import { useMemo, useState, useEffect } from 'react'; import { RecipeList } from './components/RecipeList.jsx'; import { RecipeForm } from './components/RecipeForm.jsx'; import { RecipeDetail } from './components/RecipeDetail.jsx'; import { useCollection, share } from '@deplixo/sdk'; const WEEK_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const SHARE_PARAM = 'recipe'; function normalizeIngredientLine(line) { return String(line || '') .trim() .replace(/\s+/g, ' ') .replace(/^[-*โข]+\s*/, ''); } function parseIngredientLine(line) { const text = normalizeIngredientLine(line); if (!text) return null; const quantityUnitMatch = text.match(/^([\d\/.]+(?:\s+[\d\/.]+)?)(?:\s*([a-zA-Z]+(?:\.?))?)?\s+(.*)$/); if (quantityUnitMatch) { const [, quantityRaw, unitRaw = '', nameRaw] = quantityUnitMatch; return { quantity: quantityRaw.trim(), unit: unitRaw.trim().toLowerCase(), name: nameRaw.trim(), original: text, }; } return { quantity: '', unit: '', name: text, original: text, }; } function normalizeIngredientName(name) { return String(name || '') .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .replace(/\b(fresh|large|small|medium|chopped|minced|diced|sliced|ground|optional|to taste|as needed)\b/g, ' ') .replace(/\s+/g, ' ') .trim(); } function formatMergedQuantity(items) { const quantities = items.map((item) => item.quantity).filter(Boolean); const units = items.map((item) => item.unit).filter(Boolean); const sameQuantity = quantities.length > 0 && quantities.every((q) => q === quantities[0]); const sameUnit = units.length > 0 && units.every((u) => u === units[0]); if (sameQuantity && sameUnit && quantities[0]) { return `${quantities[0]} ${units[0] ? `${units[0]} ` : ''}`.trim(); } const q = quantities.length ? quantities.join(' + ') : ''; const u = sameUnit && units.length ? units[0] : ''; return `${q}${q && u ? ' ' : ''}${u}`.trim(); } function buildShoppingList(mealPlan) { const aggregated = new Map(); mealPlan.forEach((recipe) => { if (!recipe) return; const lines = String(recipe.ingredients || '') .split('\n') .map(normalizeIngredientLine) .filter(Boolean); lines.forEach((line) => { const parsed = parseIngredientLine(line); if (!parsed) return; const key = normalizeIngredientName(parsed.name); if (!key) return; const existing = aggregated.get(key); if (existing) { existing.items.push(parsed); } else { aggregated.set(key, { name: parsed.name, items: [parsed], }); } }); }); return Array.from(aggregated.values()) .map((entry) => ({ name: entry.name, quantityText: formatMergedQuantity(entry.items), count: entry.items.length, })) .sort((a, b) => a.name.localeCompare(b.name)); } function getRecipeId(recipe) { return recipe?._id || recipe?.id || recipe?.uuid || recipe?.name || ''; } function App() { const [showForm, setShowForm] = useState(false); const [editingRecipe, setEditingRecipe] = useState(null); const [viewingRecipe, setViewingRecipe] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [mealPlan, setMealPlan] = useState(() => Array(7).fill(null)); const [draggedRecipe, setDraggedRecipe] = useState(null); const [shareStatus, setShareStatus] = useState(''); const collection = useCollection('recipes', { personal: true }); const handleEdit = (recipe) => { setEditingRecipe(recipe); setShowForm(true); }; const handleCloseForm = () => { setShowForm(false); setEditingRecipe(null); }; const handleDragStart = (recipe) => { setDraggedRecipe(recipe); }; const handleDragEnd = () => { setDraggedRecipe(null); }; const handleDropToDay = (dayIndex) => { if (!draggedRecipe) return; setMealPlan((current) => { const next = [...current]; next[dayIndex] = draggedRecipe; return next; }); setDraggedRecipe(null); }; const handleShareRecipe = async (recipe) => { const id = getRecipeId(recipe); if (!id) return; const url = new URL(window.location.href); url.searchParams.set(SHARE_PARAM, encodeURIComponent(id)); const shareUrl = url.toString(); try { if (navigator.share) { await navigator.share({ title: recipe?.name ? `${recipe.name} โ Recipe` : 'Recipe', text: `Check out this recipe: ${recipe?.name || 'Recipe'}`, url: shareUrl, }); } else if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(shareUrl); setShareStatus('Share link copied to clipboard'); setTimeout(() => setShareStatus(''), 2500); } else { window.prompt('Copy this share link:', shareUrl); } } catch { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(shareUrl); setShareStatus('Share link copied to clipboard'); setTimeout(() => setShareStatus(''), 2500); } } }; const todayIndex = useMemo(() => new Date().getDay(), []); const shoppingList = useMemo(() => buildShoppingList(mealPlan), [mealPlan]); const recipes = collection.items || []; useEffect(() => { const params = new URLSearchParams(window.location.search); const sharedId = params.get(SHARE_PARAM); if (!sharedId || !recipes.length) return; const decodedId = decodeURIComponent(sharedId); const matchedRecipe = recipes.find((recipe) => String(getRecipeId(recipe)) === String(decodedId)); if (matchedRecipe) { setViewingRecipe(matchedRecipe); } }, [recipes]); return (
Your personal recipe collection
Drag recipes from the list into any day of the week.
Combined from all recipes planned for the week.