/* @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 (
);
}
function FavoriteShareBar({ recipe, contributor, favorite, onToggleFavorite, onShare, collectionCount = 0 }) {
if (!recipe) return null;
return (
{favorite ? '★' : '☆'}
{favorite ? 'In My Favorites' : 'Add to Favorites'}
Share this recipe
{collectionCount ? `Saved in ${collectionCount} collection${collectionCount === 1 ? '' : 's'}. ` : ''}Copy a link or text to send to others.
Share / Copy Link
);
}
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 (
toggle(reaction.key)}
title={contributor?.name ? `React as ${contributor.name}` : 'React'}
aria-pressed={active}
>
{reaction.emoji}
{reaction.label}
{count || ''}
{contributor?.name && active && (
{contributor.name}
)}
);
})}
);
}
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 (
onToggleCollection(collection)}
aria-pressed={active}
>
{active ? '✓' : '+'}
{collection.name}
{collection.isDefault && Default }
{!collection.isDefault && (
setRenameValues({ ...renameValues, [collectionId]: e.target.value })}
/>
onRenameCollection(collection, renameValues[collectionId] ?? collection.name)}
>
Rename
onDeleteCollection(collection)}
>
Delete
)}
);
})}
setNewCollectionName(e.target.value)}
placeholder="New collection name"
/>
Create Collection
);
}
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' && (
navigate('list')}>
← Back
)}
🍸
Mixology
{view === 'list' && (
navigate('add')}>
+ New Recipe
)}
{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 (
Collection
updateFilter('collectionId', e.target.value)}
>
All Collections
{collections.filter((collection) => !collection.archived).map((collection) => (
{collection.name}
))}
Spirit Base
updateFilter('spiritBase', e.target.value)}
>
{spiritOptions.map((option) => (
{option}
))}
Difficulty
updateFilter('difficulty', e.target.value)}
>
{difficultyOptions.map((option) => (
{option}
))}
Occasion
updateFilter('occasion', e.target.value)}
>
{occasionOptions.map((option) => (
{option}
))}
Clear Filters
);
}
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 (
{
if (onCreateCollection) {
const name = window.prompt('Create a new collection', 'My Collection');
if (name) await onCreateCollection(name);
}
}}>
+ New Collection
{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.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()}>
onToggleFavorite?.(recipe)}
aria-pressed={favorite}
title={favorite ? 'Remove favorite' : 'Add favorite'}
>
{favorite ? '★ Favorited' : '☆ Favorite'}
onShareRecipe?.(recipe)}
title="Share recipe"
>
Share
);
})}
) : (
🍸
No recipes found
Try a different search term or adjust the filters.
+ New Recipe
)}
);
}
export { App, RecipeListWithEnhancedSearch as RecipeList };
ReactDOM.createRoot(document.getElementById('root')).render( );