/* @component-map * App — Main container, tab navigation [app.jsx] * RecipeList — Browse and search cocktail recipes [components/RecipeList.jsx] * RecipeDetail — View full recipe with shaker calculator [components/RecipeDetail.jsx] * RecipeForm — Add/edit cocktail recipes [components/RecipeForm.jsx] * @end-component-map */ import { useState, useMemo, useEffect } from 'react'; import { useCollection, useReactions, share } from '@deplixo/sdk'; import { RecipeList } from './components/RecipeList.jsx'; import { RecipeDetail } from './components/RecipeDetail.jsx'; import { RecipeForm } from './components/RecipeForm.jsx'; function ContributorIdentity({ contributor, onChange }) { return (
{contributor.avatar}
onChange({ ...contributor, name: e.target.value })} placeholder="Your name" />
); } function FavoriteShareBar({ recipe, contributor, favorite, onToggleFavorite, onShare, collectionCount = 0 }) { if (!recipe) return null; return (
Share this recipe {collectionCount ? `Saved in ${collectionCount} collection${collectionCount === 1 ? '' : 's'}. ` : ''}Copy a link or text to send to others.
); } function ReactionBar({ targetId, contributor }) { const { counts, toggle, loading, userReactions } = useReactions(targetId); const reactions = [ { key: 'delicious', emoji: '😋', label: 'Delicious' }, { key: 'strong', emoji: '💪', label: 'Strong' }, { key: 'refreshing', emoji: '🫧', label: 'Refreshing' }, { key: 'artistic', emoji: '🎨', label: 'Artistic' }, ]; if (loading) return null; return (

Reactions

{contributor?.name && ( Reacting as {contributor.name} )}
{reactions.map((reaction) => { const active = !!userReactions?.[reaction.key]; const count = counts?.[reaction.key] || 0; return ( ); })}
); } function CollectionsPanel({ recipe, collections = [], recipeCollectionIds = [], onToggleCollection, onCreateCollection, onRenameCollection, onDeleteCollection, }) { const [newCollectionName, setNewCollectionName] = useState(''); const [renameValues, setRenameValues] = useState({}); if (!recipe) return null; const handleCreate = async () => { const name = newCollectionName.trim(); if (!name) return; await onCreateCollection(name); setNewCollectionName(''); }; return (

Collections

Add this recipe to one or more collections.

{collections.map((collection) => { const active = recipeCollectionIds.includes(collection.id || collection._id); const collectionId = collection.id || collection._id; return (
{!collection.isDefault && (
setRenameValues({ ...renameValues, [collectionId]: e.target.value })} />
)}
); })}
setNewCollectionName(e.target.value)} placeholder="New collection name" />
); } function App() { const [view, setView] = useState('list'); const [selectedRecipeId, setSelectedRecipeId] = useState(null); const [editRecipeId, setEditRecipeId] = useState(null); const [contributor, setContributor] = useState({ name: 'Guest Mixer', avatar: '🧑‍🍳' }); const { items: favoriteLinks = [], add: addFavoriteLink, update: updateFavoriteLink } = useCollection('favorite-recipe-links', { personal: true }); const { items: recipeCollections = [], add: addRecipeCollection, update: updateRecipeCollection, remove: removeRecipeCollection } = useCollection('recipe-collections', { personal: true }); useCollection('contributor-profile', { personal: true }); useCollection('recipe-reactions', { personal: false }); useEffect(() => { if (typeof window === 'undefined') return; const params = new URLSearchParams(window.location.search); const recipeId = params.get('recipe'); const action = params.get('action'); if (recipeId && action === 'view') { setSelectedRecipeId(recipeId); setView('detail'); } }, []); const navigate = (newView, recipeId = null) => { setView(newView); if (newView === 'detail') setSelectedRecipeId(recipeId); if (newView === 'edit') setEditRecipeId(recipeId); if (newView === 'add') setEditRecipeId(null); }; const favoriteSet = useMemo(() => new Set((favoriteLinks || []).filter((item) => !item.archived).map((item) => item.recipeId)), [favoriteLinks]); const defaultCollection = useMemo(() => { const existing = (recipeCollections || []).find((c) => c.isDefault && c.name === 'My Favorites'); return existing || null; }, [recipeCollections]); const getRecipeCollections = (recipeId) => (recipeCollections || []).filter((collection) => Array.isArray(collection.recipeIds) && collection.recipeIds.includes(recipeId) && !collection.archived); const toggleFavorite = async (recipe) => { const recipeId = recipe?.id || recipe?._id; if (!recipeId) return; const existing = (favoriteLinks || []).find((item) => item.recipeId === recipeId); if (existing) { await updateFavoriteLink(existing.id || existing._id, { ...existing, archived: !existing.archived, updatedAt: new Date().toISOString() }); } else { await addFavoriteLink({ recipeId, recipeName: recipe.name || recipe.title || 'Recipe', recipeSnapshot: { id: recipeId, name: recipe.name || recipe.title || 'Recipe', glass: recipe.glass || '', spiritBase: recipe.spiritBase || recipe.spirit || recipe.baseSpirit || '', }, createdAt: new Date().toISOString(), archived: false, }); } }; const createCollection = async (name) => { const trimmed = String(name || '').trim(); if (!trimmed) return; await addRecipeCollection({ name: trimmed, recipeIds: [], isDefault: false, archived: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); }; const renameCollection = async (collection, nextName) => { const collectionId = collection?.id || collection?._id; const trimmed = String(nextName || '').trim(); if (!collectionId || !trimmed || collection.isDefault) return; await updateRecipeCollection(collectionId, { ...collection, name: trimmed, updatedAt: new Date().toISOString() }); }; const deleteCollection = async (collection) => { const collectionId = collection?.id || collection?._id; if (!collectionId || collection.isDefault) return; await removeRecipeCollection(collectionId); }; const ensureDefaultCollection = async () => { if (defaultCollection) return defaultCollection; const created = await addRecipeCollection({ name: 'My Favorites', recipeIds: [], isDefault: true, archived: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); return created; }; const setRecipeInCollection = async (collection, recipe, shouldAdd) => { const collectionId = collection?.id || collection?._id; const recipeId = recipe?.id || recipe?._id; if (!collectionId || !recipeId) return; const nextIds = new Set(collection.recipeIds || []); if (shouldAdd) nextIds.add(recipeId); else nextIds.delete(recipeId); await updateRecipeCollection(collectionId, { ...collection, recipeIds: Array.from(nextIds), updatedAt: new Date().toISOString() }); }; const toggleRecipeCollection = async (recipe, collection) => { const recipeId = recipe?.id || recipe?._id; if (!recipeId) return; const collectionId = collection?.id || collection?._id; const current = (collection?.recipeIds || []).includes(recipeId); await setRecipeInCollection(collection, recipe, !current); if (collection?.isDefault || collection?.name === 'My Favorites') { const existing = (favoriteLinks || []).find((item) => item.recipeId === recipeId); if (current && existing) { await updateFavoriteLink(existing.id || existing._id, { ...existing, archived: true, updatedAt: new Date().toISOString() }); } else if (!current && !existing) { await addFavoriteLink({ recipeId, recipeName: recipe.name || recipe.title || 'Recipe', recipeSnapshot: { id: recipeId, name: recipe.name || recipe.title || 'Recipe', glass: recipe.glass || '', spiritBase: recipe.spiritBase || recipe.spirit || recipe.baseSpirit || '', }, createdAt: new Date().toISOString(), archived: false, }); } else if (!current && existing && existing.archived) { await updateFavoriteLink(existing.id || existing._id, { ...existing, archived: false, updatedAt: new Date().toISOString() }); } } }; const shareRecipe = async (recipe) => { const recipeId = recipe?.id || recipe?._id; if (!recipeId) return; const baseUrl = typeof window !== 'undefined' ? window.location.origin + window.location.pathname : ''; const url = `${baseUrl}?action=view&recipe=${encodeURIComponent(recipeId)}`; const text = `Check out this cocktail recipe: ${recipe.name || recipe.title || 'Recipe'}\n${url}`; try { if (navigator?.share) { await navigator.share({ title: recipe.name || recipe.title || 'Recipe', text, url }); return; } if (navigator?.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } } catch (e) { } window.alert(text); }; return (
{view !== 'list' && ( )}
🍸

Mixology

{view === 'list' && ( )}
{view === 'list' && ( navigate('detail', id)} onAdd={() => navigate('add')} favoriteRecipeIds={Array.from(favoriteSet)} onToggleFavorite={toggleFavorite} onShareRecipe={shareRecipe} collections={recipeCollections} onCreateCollection={createCollection} onToggleRecipeCollection={toggleRecipeCollection} onRenameCollection={renameCollection} onDeleteCollection={deleteCollection} /> )} {view === 'detail' && ( <> navigate('edit', id)} onBack={() => navigate('list')} favoriteRecipeIds={Array.from(favoriteSet)} onToggleFavorite={toggleFavorite} onShareRecipe={shareRecipe} collections={recipeCollections} onToggleRecipeCollection={toggleRecipeCollection} /> )} {(view === 'add' || view === 'edit') && ( navigate('list')} onCancel={() => navigate('list')} /> )}
); } function RecipeListFilters({ filters, onChange, onClear, collections = [] }) { const spiritOptions = ['All Spirits', 'Gin', 'Vodka', 'Rum', 'Tequila', 'Whiskey', 'Brandy', 'Mezcal', 'Cognac', 'Other']; const difficultyOptions = ['All Levels', 'Easy', 'Medium', 'Hard']; const occasionOptions = ['All Occasions', 'Apéritif', 'After Dinner', 'Brunch', 'Celebration', 'Date Night', 'Party', 'Relaxing', 'Summer', 'Winter']; const updateFilter = (key, value) => { onChange({ ...filters, [key]: value }); }; return (
); } function RecipeListWithEnhancedSearch({ onSelect, onAdd, favoriteRecipeIds = [], onToggleFavorite, onShareRecipe, collections = [], onCreateCollection, onToggleRecipeCollection, onRenameCollection, onDeleteCollection }) { const { items: recipes = [], loading } = useCollection('recipes', { personal: false }); const [searchTerm, setSearchTerm] = useState(''); const [filters, setFilters] = useState({ spiritBase: '', difficulty: '', occasion: '', collectionId: '' }); const normalized = (value) => String(value || '').toLowerCase().trim(); const filteredRecipes = useMemo(() => { const query = normalized(searchTerm); const selectedCollection = filters.collectionId; return recipes.filter((recipe) => { const recipeId = recipe.id || recipe._id; const title = normalized(recipe.name || recipe.title); const ingredients = Array.isArray(recipe.ingredients) ? recipe.ingredients.map((ing) => normalized(ing.name || ing)).join(' ') : normalized(recipe.ingredients); const notes = normalized(recipe.notes); const instructions = normalized(recipe.instructions); const spiritBase = normalized(recipe.spiritBase || recipe.spirit || recipe.baseSpirit); const difficulty = normalized(recipe.difficulty); const occasion = normalized(recipe.occasion || recipe.occassion); const isInSelectedCollection = !selectedCollection || (collections || []).some((collection) => (collection.id || collection._id) === selectedCollection && Array.isArray(collection.recipeIds) && collection.recipeIds.includes(recipeId) && !collection.archived); const matchesSearch = !query || title.includes(query) || ingredients.includes(query) || notes.includes(query) || instructions.includes(query) || spiritBase.includes(query) || difficulty.includes(query) || occasion.includes(query); const matchesSpirit = !filters.spiritBase || spiritBase === normalized(filters.spiritBase); const matchesDifficulty = !filters.difficulty || difficulty === normalized(filters.difficulty); const matchesOccasion = !filters.occasion || occasion === normalized(filters.occasion); return matchesSearch && matchesSpirit && matchesDifficulty && matchesOccasion && isInSelectedCollection; }); }, [recipes, searchTerm, filters, collections]); const clearFilters = () => { setFilters({ spiritBase: '', difficulty: '', occasion: '', collectionId: '' }); setSearchTerm(''); }; return (
🔎 setSearchTerm(e.target.value)} placeholder="Search recipes, ingredients, spirit base, difficulty, or occasion" aria-label="Search recipes" /> {searchTerm && ( )}
{collections?.filter((c) => !c.archived).slice(0, 3).map((collection) => ( {collection.name} ))}
{loading ? 'Loading recipes…' : `${filteredRecipes.length} recipe${filteredRecipes.length === 1 ? '' : 's'} found`}
{filteredRecipes.length > 0 ? (
{filteredRecipes.map((recipe) => { const recipeId = recipe.id || recipe._id; const favorite = favoriteRecipeIds.includes(recipeId); const recipeCollections = (collections || []).filter((collection) => Array.isArray(collection.recipeIds) && collection.recipeIds.includes(recipeId) && !collection.archived); return (
onSelect(recipeId)}>
{recipe.photoUrl ? ( {recipe.name ) : (
🍹
)}

{recipe.name || recipe.title}

{recipe.glass && {recipe.glass}} {recipe.spiritBase && {recipe.spiritBase}} {recipe.difficulty && {recipe.difficulty}} {recipe.occasion && {recipe.occasion}}
{recipeCollections.slice(0, 3).map((collection) => ( {collection.name} ))}
e.stopPropagation()}>
); })}
) : (
🍸

No recipes found

Try a different search term or adjust the filters.

)}
); } export { App, RecipeListWithEnhancedSearch as RecipeList }; ReactDOM.createRoot(document.getElementById('root')).render();