feat(ui): meez visual alignment and unified responsive layouts across desktop and mobile
- Resolve ingredient table overflow and 2-column layout on laptop screens (1280px-1440px) - Align recipe, ingredient, and directory pages with meez visual design specifications - Implement segmented icon navigation tabs across recipe and ingredient detail views - Standardize top utility bar and entity detail header alignment across all viewports - Fix mobile home page filter popup z-index and viewport overflow clipping - Clean up legacy prototype media queries and consolidate responsive CSS system
This commit is contained in:
@@ -2,6 +2,18 @@
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
||||
import EntityDirectory from "../../../components/EntityDirectory";
|
||||
const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase",string> = {
|
||||
recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z",
|
||||
ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z",
|
||||
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"
|
||||
};
|
||||
const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase",string|undefined> = {
|
||||
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
|
||||
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
|
||||
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
|
||||
purchase:undefined
|
||||
};
|
||||
import { openDatabase } from "../../../lib/database";
|
||||
import { readOnlyMode } from "../../../lib/runtime";
|
||||
import { titleCase } from "../../../lib/format";
|
||||
@@ -23,8 +35,8 @@ const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_
|
||||
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[];
|
||||
database.close();
|
||||
|
||||
const requested=Astro.url.searchParams.get("type")??"ingredient";
|
||||
const type=["recipe","ingredient","book","purchase"].includes(requested)?requested:"ingredient";
|
||||
const requested=Astro.url.searchParams.get("type");
|
||||
const type=["recipe","ingredient","book","purchase"].includes(requested??"")?requested:undefined;
|
||||
const query=(Astro.url.searchParams.get("q")??"").trim();
|
||||
const normalizedQuery=query.toLocaleLowerCase();
|
||||
const validSearchTypes=["recipe","ingredient","book","purchase"];
|
||||
@@ -34,60 +46,66 @@ const attention=Astro.url.searchParams.get("attention")==="1", missingCost=Astro
|
||||
const filtering=attention||missingCost||noUsda||unused||emptyRecipe||placeholderSteps;
|
||||
const filteredIngredients=ingredients.filter((ingredient)=>{if(!filtering)return true;const selected=[missingCost&&ingredient.price_count===0,noUsda&&ingredient.nutrition_count===0,unused&&ingredient.recipe_count===0].filter(Boolean);return missingCost||noUsda||unused?selected.length>0:ingredient.price_count===0||ingredient.nutrition_count===0||ingredient.recipe_count===0;});
|
||||
const filteredRecipes=recipes.filter(recipe=>!filtering||(emptyRecipe&&recipe.item_count===0)||(placeholderSteps&&recipe.placeholder_count>0)||(!emptyRecipe&&!placeholderSteps&&(recipe.item_count===0||recipe.placeholder_count>0)));
|
||||
const tabs=[
|
||||
{type:"recipe",label:"Recipes",count:recipes.length,icon:"▦",kind:"recipe"},
|
||||
{type:"ingredient",label:"Ingredients",count:ingredients.length,icon:"●",kind:"ingredient"},
|
||||
{type:"book",label:"Recipe books",count:books.length,icon:"▣",kind:"book"},
|
||||
{type:"purchase",label:"Purchase items",count:purchases.length,icon:"$",kind:"purchase"},
|
||||
const FILTER_ICON_PATH="M18 6.0201C18 4.81608 17.1873 3.79266 16.0736 3.46156L16.0736 0.722412C16.0736 0.301005 15.7425 -9.86801e-08 15.3211 -1.171e-07C14.8996 -1.35521e-07 14.5685 0.331105 14.5685 0.752512L14.5685 3.49166C13.4548 3.79266 12.6421 4.84618 12.6421 6.0502C12.6421 7.28432 13.4548 8.30774 14.5685 8.63884L14.5685 19.7459C14.5685 20.1673 14.8996 20.4984 15.3211 20.4984C15.7425 20.4984 16.0736 20.1673 16.0736 19.7459L16.0736 8.63884C17.1873 8.27764 18 7.25422 18 6.0201ZM16.495 6.0502C16.495 6.68231 15.9833 7.22412 15.3211 7.22412C14.6588 7.22412 14.1471 6.68231 14.1471 6.0502C14.1471 5.41809 14.6588 4.87628 15.3211 4.87628C15.9833 4.87628 16.495 5.41809 16.495 6.0502Z";
|
||||
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"}>=[
|
||||
{type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"},
|
||||
{type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"},
|
||||
{type:"book",label:"Recipe books",count:books.length,kind:"book"},
|
||||
{type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"},
|
||||
];
|
||||
const allSearchResults=normalizedQuery ? [
|
||||
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"recipe",label:"Recipe",icon:"▦",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
|
||||
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
|
||||
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"book",label:"Recipe book",icon:"▣",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:"/app/?type=book"})),
|
||||
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"purchase",label:"Purchase item",icon:"$",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})),
|
||||
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
|
||||
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
|
||||
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})),
|
||||
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})),
|
||||
].sort((a,b)=>a.name.localeCompare(b.name)):[];
|
||||
const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults;
|
||||
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const,icon:"●"}));
|
||||
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const,icon:"▦"}));
|
||||
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const,icon:"▣"}));
|
||||
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const,icon:"$"}));
|
||||
const searchRows=searchResults.map(({id,kind,name,href,label,detail})=>({id,name,href,kind,detail:`${label} · ${detail}`}));
|
||||
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const}));
|
||||
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const}));
|
||||
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const}));
|
||||
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const}));
|
||||
---
|
||||
<BaseLayout title="Recipe management"><section class="shell directory-workspace">
|
||||
<div class="workspace-search-tools">
|
||||
<BaseLayout title="Recipe management"><div class="workspace-search-tools"><div class="workspace-search-tools-inner">
|
||||
<form class="workspace-global-search" method="get" action="/app/" role="search">
|
||||
<input type="hidden" name="type" value={type}/>
|
||||
<input type="hidden" name="type" value={type??""}/>
|
||||
{selectedSearchTypes.map((selectedType)=><input type="hidden" name="item_type" value={selectedType}/>)}
|
||||
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input type="search" name="q" value={query} placeholder="Search " aria-label="Search all items" autofocus={Boolean(query)}/>
|
||||
{query&&<a href={`/app/?type=${type}`} aria-label="Clear search">×</a>}
|
||||
{query&&<a href={type?`/app/?type=${type}`:"/app/"} aria-label="Clear search">×</a>}
|
||||
</form>
|
||||
<details class="search-type-filter" open={filteringSearchTypes}>
|
||||
<summary>☷ Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary>
|
||||
<form method="get" action="/app/"><input type="hidden" name="type" value={type}/><input type="hidden" name="q" value={query}/>
|
||||
<summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary>
|
||||
<form method="get" action="/app/"><input type="hidden" name="type" value={type??""}/><input type="hidden" name="q" value={query}/>
|
||||
<label><input type="checkbox" name="item_type" value="recipe" checked={selectedSearchTypes.includes("recipe")}/> Recipes</label>
|
||||
<label><input type="checkbox" name="item_type" value="ingredient" checked={selectedSearchTypes.includes("ingredient")}/> Ingredients</label>
|
||||
<label><input type="checkbox" name="item_type" value="book" checked={selectedSearchTypes.includes("book")}/> Recipe books</label>
|
||||
<label><input type="checkbox" name="item_type" value="purchase" checked={selectedSearchTypes.includes("purchase")}/> Purchase items</label>
|
||||
<div><button>Apply</button>{filteringSearchTypes&&<a href={`/app/?type=${type}&q=${encodeURIComponent(query)}`}>All types</a>}</div>
|
||||
<div><button>Apply</button>{filteringSearchTypes&&<a href={type?`/app/?type=${type}&q=${encodeURIComponent(query)}`:`/app/?q=${encodeURIComponent(query)}`}>All types</a>}</div>
|
||||
</form>
|
||||
</details>
|
||||
{!readOnlyMode&&<details class="workspace-new-menu">
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true">+</span><span>New</span></summary>
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span class="new-trigger-label">New</span></summary>
|
||||
<nav aria-label="Create new item">
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a>
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.recipe} style={TYPE_ICON_TRANSFORMS.recipe?{transform:TYPE_ICON_TRANSFORMS.recipe}:undefined}/></svg></span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.book} style={TYPE_ICON_TRANSFORMS.book?{transform:TYPE_ICON_TRANSFORMS.book}:undefined}/></svg></span><strong>Recipe book</strong></a>
|
||||
</nav>
|
||||
</details>}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="workspace-pills" aria-label="Workspaces">
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={`/app/?type=${tab.type}`}>{tab.label} <small>{tab.count}</small></a>)}
|
||||
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary>☷ Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
|
||||
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary>☷ Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
|
||||
<section class="shell directory-workspace">
|
||||
<nav class="workspace-pills" aria-label="Workspaces">
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={`/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
|
||||
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
|
||||
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
|
||||
{!readOnlyMode&&<a class="archive-link" href="/app/archive/">Archive</a>}
|
||||
</nav>
|
||||
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchResults.length?<div>{searchResults.map((result)=><a href={result.href}><span class={`workspace-pill-icon ${result.kind}`}>{result.icon}</span><span><strong>{result.name}</strong><small>{result.detail}</small></span><em>{result.label}</em><b>›</b></a>)}</div>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
|
||||
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchRows.length?<EntityDirectory client:load rows={searchRows} emptyMessage="No items of the selected types match this search." readOnly={readOnlyMode}/>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
|
||||
{type==="ingredient"&&<EntityDirectory client:load rows={ingredientRows} entityType="ingredient" emptyMessage="No ingredients match these filters." readOnly={readOnlyMode}/>}
|
||||
{type==="recipe"&&<EntityDirectory client:load rows={recipeRows} entityType="recipe" emptyMessage="No recipes yet." readOnly={readOnlyMode}/>}
|
||||
{type==="book"&&<EntityDirectory client:load rows={bookRows} entityType="book" emptyMessage="No recipe books yet." readOnly={readOnlyMode}/>}
|
||||
{type==="purchase"&&<EntityDirectory client:load rows={purchaseRows} entityType="purchase" emptyMessage="No purchase items yet." readOnly={readOnlyMode}/>}
|
||||
{!type&&<p class="empty-state">Select a workspace or search to browse recipes, ingredients, and purchase items.</p>}
|
||||
</>}
|
||||
</section></BaseLayout>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -126,29 +126,333 @@ const prepDisplay = prep.map((row) => {
|
||||
return measure ? `${number(measure.quantity)} ${unitById.get(measure.unit_id)?.symbol ?? measure.unit_id}` : "—";
|
||||
};
|
||||
return {...row,weight:showMeasure("mass"),volume:showMeasure("volume"),each:showMeasure("count")};
|
||||
});
|
||||
});const RECIPE_TAB_ICONS = {
|
||||
overview: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z",
|
||||
costing: "M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4",
|
||||
equivalencies: "M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z",
|
||||
nutrition: "M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z"
|
||||
};
|
||||
const RECIPE_TAB_VIEWBOX = { overview: "0 0 24 24", costing: "0 0 24 24", equivalencies: "0 0 24 24", nutrition: "0 0 20 18" };
|
||||
const recipeTabIcon = (name: string) => `<span class="recipe-tab-icon"><svg viewBox="${RECIPE_TAB_VIEWBOX[name as keyof typeof RECIPE_TAB_VIEWBOX]}" aria-hidden="true"><path fill="currentColor" d="${RECIPE_TAB_ICONS[name as keyof typeof RECIPE_TAB_ICONS]}"/></svg></span>`;
|
||||
database.close();
|
||||
---
|
||||
<BaseLayout title={titleCase(ingredient.name)} immersive>
|
||||
<section class="entity-detail-shell">
|
||||
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient" />
|
||||
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>{editing?<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>:<h1>{titleCase(ingredient.name)}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>:<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}>✎ Edit</a>}{editing&&<details class="detail-actions-menu ingredient-actions-menu"><summary aria-label="Ingredient actions">⋮</summary><div><form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it."><label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label><button name="intent" value="merge">Merge ingredient</button></form></div></details>}</div>}</header>
|
||||
{message&&<div class="success-notice entity-notice">{message}</div>}{error&&<div class="notice entity-notice">{error}</div>}
|
||||
<div class="entity-detail-grid">
|
||||
<main class="entity-primary">
|
||||
{editing&&<form method="post" id="ingredient-identity-form" class="ingredient-identity-form ingredient-identity-data"><input type="hidden" name="intent" value="identity"/><input type="hidden" name="status" value={ingredient.status}/><input type="hidden" id="ingredient-prep-json" name="prep_json" value="[]"/></form>}
|
||||
<section id="prep" class="entity-section ingredient-prep-section"><h2>Prep Actions</h2><p class="panel-intro">Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.</p>{editing?<><div class="prep-edit-wrap"><table class="prep-action-table prep-edit-table"><thead><tr><th>Prep Action</th><th>Yield %</th><th>Weight</th><th>Volume</th><th>Each</th><th></th></tr></thead><tbody id="ingredient-prep-rows" data-actions={JSON.stringify(actions)}>{prepDisplay.map(x=><tr class="prep-edit-row"><td><select class="prep-row-action" aria-label="Prep action">{actions.map(action=><option value={action.id} selected={action.id===x.action_id}>{action.name}</option>)}</select><input class="prep-row-notes" value={x.notes??""} placeholder="Optional notes" aria-label="Prep action notes"/></td><td><span class="percent-input"><input class="prep-row-yield" type="number" min="0.01" step="0.01" required value={x.yield_factor*100} aria-label="Yield percent"/><i>%</i></span></td>{[x.weight,x.volume,x.each].map(value=><td><button class="prep-equivalency-link" type="button" title="Edit this prep action's UoM equivalency">{value==="—"?"Set":value}</button></td>)}<td><button class="prep-row-remove" type="button" aria-label={`Remove ${x.name}`}>×</button></td></tr>)}</tbody></table></div><button id="add-prep-row" class="outlined-add-action" type="button">+ Add Prep Action</button><p class="field-help">Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.</p></>:prep.length?<table class="prep-action-table"><thead><tr><th>Prep Action</th><th>Yield %</th><th>Weight</th><th>Volume</th><th>Each</th></tr></thead><tbody>{prepDisplay.map(x=><tr><td><strong>{x.name}</strong>{x.notes&&<small>{x.notes}</small>}</td><td>{number(x.yield_factor*100)}%</td><td>{x.weight}</td><td>{x.volume}</td><td>{x.each}</td></tr>)}</tbody></table>:<p class="empty-copy">No prep actions defined.</p>}</section>
|
||||
<section id="usage" class:list={["entity-section","additional-card",{"ingredient-additional-edit":editing}]}><h2>Additional Details</h2>{editing?<div class="ingredient-detail-fields"><label><span>Tags</span><input id="ingredient-tags" name="tags" form="ingredient-identity-form" value={ingredientTags.join(", ")} placeholder="Tag Name"/></label><label><span>Description</span><textarea name="description" form="ingredient-identity-form" rows="4" placeholder="Write your ingredient description">{ingredient.description??""}</textarea></label><label><span>Ingredient aliases</span><small>One alias per line</small><textarea name="aliases" form="ingredient-identity-form" rows="4">{aliases.map(x=>x.name).join("\n")}</textarea></label></div>:<><details open><summary>Recipes On <small>{usedIn.length}</small></summary>{usedIn.length?<ul>{usedIn.map(x=><li><a href={`/app/recipes/${x.id}/`}>{x.title}</a></li>)}</ul>:<p>This ingredient is not used by a recipe.</p>}</details>{ingredientTags.length>0&&<div class="ingredient-tag-view"><strong>Tags</strong><span>{ingredientTags.map(tag=><i>{tag}</i>)}</span></div>}{ingredient.description&&<div class="ingredient-description-view"><strong>Description</strong><p>{ingredient.description}</p></div>}<details><summary>Ingredient aliases <small>{aliases.length}</small></summary>{aliases.length?<ul>{aliases.map(x=><li>{x.name}</li>)}</ul>:<p>No aliases.</p>}</details></>}</section>
|
||||
<BaseLayout title={editing ? `Edit ${titleCase(ingredient.name)}` : titleCase(ingredient.name)} immersive>
|
||||
<section class="recipe-detail-shell recipe-read-shell ingredient-read-shell">
|
||||
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient">
|
||||
<div class="entity-header-actions">
|
||||
{editing ? (
|
||||
<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>
|
||||
) : !readOnlyMode && (
|
||||
<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
|
||||
)}
|
||||
{editing && (
|
||||
<details class="detail-actions-menu ingredient-actions-menu">
|
||||
<summary aria-label="Ingredient actions">⋮</summary>
|
||||
<div>
|
||||
<form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it.">
|
||||
<label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label>
|
||||
<button name="intent" value="merge">Merge ingredient</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</DetailUtility>
|
||||
|
||||
<div class="recipe-read-left ingredient-read-left">
|
||||
<header class="entity-detail-header">
|
||||
<div>
|
||||
<p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>
|
||||
{editing ? (
|
||||
<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>
|
||||
) : (
|
||||
<h1>{titleCase(ingredient.name)}</h1>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{message && <div class="success-notice entity-notice">{message}</div>}
|
||||
{error && <div class="notice entity-notice">{error}</div>}
|
||||
|
||||
<main class="ingredient-main-content entity-tab-panel active" data-panel="overview">
|
||||
{editing && (
|
||||
<form method="post" id="ingredient-identity-form" class="ingredient-identity-form ingredient-identity-data">
|
||||
<input type="hidden" name="intent" value="identity"/>
|
||||
<input type="hidden" name="status" value={ingredient.status}/>
|
||||
<input type="hidden" id="ingredient-prep-json" name="prep_json" value="[]"/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<section id="prep" class="entity-section ingredient-prep-section">
|
||||
<h2>Prep Actions</h2>
|
||||
<p class="panel-intro">Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.</p>
|
||||
{editing ? (
|
||||
<>
|
||||
<button id="add-prep-row" class="outlined-add-action" type="button">+ Add Prep Action</button>
|
||||
<div class="prep-edit-wrap">
|
||||
<table class="prep-action-table prep-edit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:35%;">Prep Action</th>
|
||||
<th style="width:18%;">Yield %</th>
|
||||
<th style="width:15%;">Weight</th>
|
||||
<th style="width:15%;">Volume</th>
|
||||
<th style="width:12%;">Each</th>
|
||||
<th style="width:5%;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ingredient-prep-rows" data-actions={JSON.stringify(actions)}>
|
||||
{prepDisplay.map(x => (
|
||||
<tr class="prep-edit-row">
|
||||
<td>
|
||||
<select class="prep-row-action" aria-label="Prep action">
|
||||
{actions.map(action => (
|
||||
<option value={action.id} selected={action.id === x.action_id}>{action.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input class="prep-row-notes" value={x.notes ?? ""} placeholder="Optional notes" aria-label="Prep action notes"/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="percent-input">
|
||||
<input class="prep-row-yield" type="number" min="0.01" step="0.01" required value={x.yield_factor * 100} aria-label="Yield percent"/>
|
||||
<i>%</i>
|
||||
</span>
|
||||
</td>
|
||||
{[x.weight, x.volume, x.each].map(value => (
|
||||
<td>
|
||||
<button class="prep-equivalency-link" type="button" title="Edit this prep action's UoM equivalency">{value === "—" ? "Set" : value}</button>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<button class="prep-row-remove" type="button" aria-label={`Remove ${x.name}`}>×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="field-help">Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.</p>
|
||||
</>
|
||||
) : prep.length ? (
|
||||
<table class="prep-action-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">Prep Action</th>
|
||||
<th style="width:20%;">Yield %</th>
|
||||
<th style="width:15%;">Weight</th>
|
||||
<th style="width:15%;">Volume</th>
|
||||
<th style="width:10%;">Each</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prepDisplay.map(x => (
|
||||
<tr>
|
||||
<td><strong>{x.name}</strong>{x.notes && <small>{x.notes}</small>}</td>
|
||||
<td>{number(x.yield_factor * 100)}%</td>
|
||||
<td>{x.weight}</td>
|
||||
<td>{x.volume}</td>
|
||||
<td>{x.each}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p class="empty-copy">This ingredient currently has no prep actions. Edit ingredient to add prep actions.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="usage" class:list={["entity-section", "additional-card", { "ingredient-additional-edit": editing }]}>
|
||||
<h2>Additional Details</h2>
|
||||
{editing ? (
|
||||
<div class="ingredient-detail-fields">
|
||||
<label>
|
||||
<span>Tags</span>
|
||||
<input id="ingredient-tags" name="tags" form="ingredient-identity-form" value={ingredientTags.join(", ")} placeholder="Tag Name"/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea name="description" form="ingredient-identity-form" rows="4" placeholder="Write your ingredient description">{ingredient.description ?? ""}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Ingredient aliases</span>
|
||||
<small>One alias per line</small>
|
||||
<textarea name="aliases" form="ingredient-identity-form" rows="4">{aliases.map(x => x.name).join("\n")}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<details open>
|
||||
<summary>Recipes On <small>{usedIn.length}</small></summary>
|
||||
{usedIn.length ? <ul>{usedIn.map(x => <li><a href={`/app/recipes/${x.id}/`}>{x.title}</a></li>)}</ul> : <p>This ingredient is not used by a recipe.</p>}
|
||||
</details>
|
||||
{ingredientTags.length > 0 && (
|
||||
<div class="ingredient-tag-view">
|
||||
<strong>Tags</strong>
|
||||
<span>{ingredientTags.map(tag => <i>{tag}</i>)}</span>
|
||||
</div>
|
||||
)}
|
||||
{ingredient.description && (
|
||||
<div class="ingredient-description-view">
|
||||
<strong>Description</strong>
|
||||
<p>{ingredient.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<details>
|
||||
<summary>Ingredient aliases <small>{aliases.length}</small></summary>
|
||||
{aliases.length ? <ul>{aliases.map(x => <li>{x.name}</li>)}</ul> : <p>No aliases.</p>}
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
<aside class="entity-secondary">
|
||||
<nav class="entity-tabs" aria-label="Ingredient details"><button class="active" type="button" data-tab="costs">$ <span>Cost</span></button><button type="button" data-tab="equivalencies">⚖ <span>UoM Equivalency</span></button><button type="button" data-tab="nutrition">♡ <span>Nutrition</span></button></nav>
|
||||
<section id="costs" class="workspace-panel entity-tab-panel active" data-panel="costs"><h2>Ingredient Cost</h2><p class="panel-intro">Purchase packages, usable yield, and current prices.</p>{editing?<div class="ingredient-cost-edit">{purchases.map(x=><section class="ingredient-purchase-form"><header><h3>{x.name}</h3><form method="post" data-confirm-message="Remove this purchase item and its price history?"><input type="hidden" name="purchase_item_id" value={x.id}/><button name="intent" value="delete_purchase_item">Remove</button></form></header><PurchaseItemForm item={x} units={units}/></section>)}<details class="purchase-editor add-purchase" open={purchases.length===0}><summary>+ Add purchase item</summary><PurchaseItemForm units={units}/></details></div>:purchases.length?purchases.map(x=><div class="ingredient-cost-row"><span><strong>{x.name}</strong><small>{x.supplier_id||"No supplier"} · {x.status}</small></span><span><strong>{x.latest_price!=null?`${x.latest_currency} ${Number(x.latest_price).toFixed(2)}`:"No price"}</strong><small>{number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol??x.package_unit_id}{x.units_per_case>1?` × ${x.units_per_case}`:""}</small></span></div>):<p class="empty-copy">No purchase cost has been entered.</p>}</section>
|
||||
<section id="equivalencies" class="workspace-panel entity-tab-panel" data-panel="equivalencies"><h2>UoM Equivalency</h2><p class="panel-intro">Define equivalent weight, volume, or count measurements for this ingredient.</p><div class="equivalency-list">{densities.map(x=><p><strong>{number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol??x.volume_unit_id}</strong><span>=</span><strong>{number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol??x.mass_unit_id}</strong><small>{x.state||"Density"} · sourced density</small></p>)}{conversionRows.map(x=><div class="equivalency-row"><p><strong>{number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol??x.from_unit_id}</strong><span>=</span><strong>{number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol??x.to_unit_id}</strong><small>{x.state||"Conversion"} · {x.isManual?"manual":x.source.title||"sourced"}</small></p>{editing&&x.isManual&&<div class="equivalency-actions"><button type="button" class="edit-equivalency" data-id={x.id} data-from-quantity={x.from_quantity} data-from-unit={x.from_unit_id} data-to-quantity={x.to_quantity} data-to-unit={x.to_unit_id} data-state={x.state??""}>Edit</button><form method="post" data-confirm-message="Remove this equivalency?"><input type="hidden" name="conversion_id" value={x.id}/><button name="intent" value="delete_conversion">Delete</button></form></div>}</div>)}</div>{!densities.length&&!conversions.length&&<p class="empty-copy">No equivalencies have been defined.</p>}{editing&&<details class="inline-editor equivalency-editor"><summary>+ Add Equivalency</summary><form method="post" class="inline-form"><input type="hidden" name="conversion_id" value=""/><label><span>From amount</span><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/></label><label><span>From unit</span><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label><i>=</i><label><span>To amount</span><input name="to_quantity" type="number" min="0.0001" step="any" required/></label><label><span>To unit</span><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label><label><span>Preparation state</span><input name="state" placeholder="e.g. chopped"/></label><button name="intent" value="conversion">Add</button><button type="button" class="cancel-equivalency" hidden>Cancel</button></form></details>}</section>
|
||||
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition"><h2>Nutrition</h2><p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>{editing&&<form method="post" class="usda-id-form"><label><span>USDA FoodData Central ID</span><input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/></label><button name="intent" value="usda_mapping">Update from USDA</button>{usdaMapping?.sourceUrl&&<a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}</form>}{usdaMapping?<article class="ingredient-nutrition"><div class="nutrition-mapping-head"><div><strong>{usdaMapping.source.title}</strong><small>{usdaMapping.status} · USDA FoodData Central</small></div>{usdaMapping.sourceUrl&&<a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}</div>{usdaMapping.nutrients.length?<dl>{usdaMapping.nutrients.map((entry:[string,number])=>{const[key,value]=entry;const[label,unit]=nutrientLabels[key]??[key.replaceAll("_"," "),""];return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>})}</dl>:<p class="notice">No reviewed nutrient values stored.</p>}<footer>{usdaMapping.source.external_id&&<span>Record {usdaMapping.source.external_id}</span>}{usdaMapping.source.retrieved_at&&<span>Retrieved {usdaMapping.source.retrieved_at}</span>}</footer></article>:<p class="empty-copy">No USDA record mapped.{editing&&" Enter an FDC ID above."}</p>}</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="recipe-read-right ingredient-read-right">
|
||||
<div class="recipe-workspace-tabs recipe-read-tabs entity-tabs" aria-label="Ingredient details">
|
||||
<button class="mobile-only-tab active" type="button" data-tab="overview"><Fragment set:html={recipeTabIcon("overview")}/><span class="recipe-tab-label">Details</span></button>
|
||||
<button type="button" data-tab="costs"><Fragment set:html={recipeTabIcon("costing")}/><span class="recipe-tab-label">Cost</span></button>
|
||||
<button type="button" data-tab="equivalencies"><Fragment set:html={recipeTabIcon("equivalencies")}/><span class="recipe-tab-label">UoM Equivalency</span></button>
|
||||
<button type="button" data-tab="nutrition"><Fragment set:html={recipeTabIcon("nutrition")}/><span class="recipe-tab-label">Nutrition</span></button>
|
||||
</div>
|
||||
|
||||
<div class="recipe-view-details ingredient-view-details">
|
||||
<section id="costs" class="workspace-panel entity-tab-panel active" data-panel="costs">
|
||||
<h2>Ingredient Cost</h2>
|
||||
<p class="panel-intro">Purchase packages, usable yield, and current prices.</p>
|
||||
{editing ? (
|
||||
<div class="ingredient-cost-edit">
|
||||
<details class="purchase-editor add-purchase" open={purchases.length === 0}>
|
||||
<summary>+ Add purchase item</summary>
|
||||
<PurchaseItemForm units={units}/>
|
||||
</details>
|
||||
{purchases.map(x => (
|
||||
<section class="ingredient-purchase-form">
|
||||
<header>
|
||||
<h3>{x.name}</h3>
|
||||
<form method="post" data-confirm-message="Remove this purchase item and its price history?">
|
||||
<input type="hidden" name="purchase_item_id" value={x.id}/>
|
||||
<button name="intent" value="delete_purchase_item">Remove</button>
|
||||
</form>
|
||||
</header>
|
||||
<PurchaseItemForm item={x} units={units}/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : purchases.length ? (
|
||||
purchases.map(x => (
|
||||
<div class="ingredient-cost-row">
|
||||
<span>
|
||||
<strong>{x.name}</strong>
|
||||
<small>{x.supplier_id || "No supplier"} · {x.status}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{x.latest_price != null ? `${x.latest_currency} ${Number(x.latest_price).toFixed(2)}` : "No price"}</strong>
|
||||
<small>{number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol ?? x.package_unit_id}{x.units_per_case > 1 ? ` × ${x.units_per_case}` : ""}</small>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p class="empty-copy">No purchase cost has been entered.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="equivalencies" class="workspace-panel entity-tab-panel" data-panel="equivalencies">
|
||||
<h2>U of M Equivalency</h2>
|
||||
<p class="panel-intro"><strong>XX Weight = XX Volume = XX Each</strong><br>You can define a custom ingredient conversion from weight to volume and to a pc/each of the ingredient.</p>
|
||||
<div class="equivalency-list">
|
||||
{densities.map(x => (
|
||||
<p>
|
||||
<strong>{number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol ?? x.volume_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol ?? x.mass_unit_id}</strong>
|
||||
<small>{x.state || "Density"} · sourced density</small>
|
||||
</p>
|
||||
))}
|
||||
{conversionRows.map(x => (
|
||||
<div class="equivalency-row">
|
||||
<p>
|
||||
<strong>{number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol ?? x.from_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol ?? x.to_unit_id}</strong>
|
||||
<small>{x.state || "Conversion"} · {x.isManual ? "manual" : x.source.title || "sourced"}</small>
|
||||
</p>
|
||||
{editing && x.isManual && (
|
||||
<div class="equivalency-actions">
|
||||
<button type="button" class="edit-equivalency" data-id={x.id} data-from-quantity={x.from_quantity} data-from-unit={x.from_unit_id} data-to-quantity={x.to_quantity} data-to-unit={x.to_unit_id} data-state={x.state ?? ""}>Edit</button>
|
||||
<form method="post" data-confirm-message="Remove this equivalency?">
|
||||
<input type="hidden" name="conversion_id" value={x.id}/>
|
||||
<button name="intent" value="delete_conversion">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!densities.length && !conversions.length && <p class="empty-copy">No equivalencies have been defined.</p>}
|
||||
{editing && (
|
||||
<details class="inline-editor equivalency-editor">
|
||||
<summary>+ Add Equivalency</summary>
|
||||
<form method="post" class="inline-form">
|
||||
<input type="hidden" name="conversion_id" value=""/>
|
||||
<label><span>From amount</span><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/></label>
|
||||
<label><span>From unit</span><select name="from_unit_id">{units.map(x => <option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label>
|
||||
<i>=</i>
|
||||
<label><span>To amount</span><input name="to_quantity" type="number" min="0.0001" step="any" required/></label>
|
||||
<label><span>To unit</span><select name="to_unit_id">{units.map(x => <option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label>
|
||||
<label><span>Preparation state</span><input name="state" placeholder="e.g. chopped"/></label>
|
||||
<button name="intent" value="conversion">Add</button>
|
||||
<button type="button" class="cancel-equivalency" hidden>Cancel</button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition">
|
||||
<h2>Nutrition</h2>
|
||||
<p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>
|
||||
{editing && (
|
||||
<form method="post" class="usda-id-form">
|
||||
<label>
|
||||
<span>USDA FoodData Central ID</span>
|
||||
<input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/>
|
||||
</label>
|
||||
<button name="intent" value="usda_mapping">Update from USDA</button>
|
||||
{usdaMapping?.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}
|
||||
</form>
|
||||
)}
|
||||
{usdaMapping ? (
|
||||
<article class="ingredient-nutrition">
|
||||
<div class="nutrition-mapping-head">
|
||||
<div>
|
||||
<strong>{usdaMapping.source.title}</strong>
|
||||
<small>{usdaMapping.status} · USDA FoodData Central</small>
|
||||
</div>
|
||||
{usdaMapping.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}
|
||||
</div>
|
||||
{usdaMapping.nutrients.length ? (
|
||||
<dl>
|
||||
{usdaMapping.nutrients.map((entry: [string, number]) => {
|
||||
const [key, value] = entry;
|
||||
const [label, unit] = nutrientLabels[key] ?? [key.replaceAll("_", " "), ""];
|
||||
return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>;
|
||||
})}
|
||||
</dl>
|
||||
) : (
|
||||
<p class="notice">No reviewed nutrient values stored.</p>
|
||||
)}
|
||||
<footer>
|
||||
{usdaMapping.source.external_id && <span>Record {usdaMapping.source.external_id}</span>}
|
||||
{usdaMapping.source.retrieved_at && <span>Retrieved {usdaMapping.source.retrieved_at}</span>}
|
||||
</footer>
|
||||
</article>
|
||||
) : (
|
||||
<p class="empty-copy">No USDA record mapped.{editing && " Enter an FDC ID above."}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{editing&&<script is:inline>
|
||||
{editing && <script is:inline>
|
||||
const ingredientForm=document.querySelector('#ingredient-identity-form'),doneButton=document.querySelector('#ingredient-done'),prepRows=document.querySelector('#ingredient-prep-rows'),prepJson=document.querySelector('#ingredient-prep-json');
|
||||
let ingredientDirty=false,ingredientSubmitting=false;
|
||||
const setIngredientDirty=(value=true)=>{ingredientDirty=value;doneButton?.classList.toggle('dirty',value)};
|
||||
@@ -185,7 +489,25 @@ database.close();
|
||||
const rows=[...prepRows.querySelectorAll('.prep-edit-row')],values=rows.map(row=>({action_id:row.querySelector('.prep-row-action').value,yield_percent:Number(row.querySelector('.prep-row-yield').value),notes:row.querySelector('.prep-row-notes').value.trim()})),ids=values.map(row=>row.action_id);
|
||||
if(new Set(ids).size!==ids.length){alert('Each prep action can only appear once.');return}if(values.some(row=>!Number.isFinite(row.yield_percent)||row.yield_percent<=0)){alert('Each prep yield must be greater than 0%.');return}prepJson.value=JSON.stringify(values);if(!ingredientForm.reportValidity())return;doneButton.disabled=true;doneButton.textContent='Saving…';ingredientForm.requestSubmit();
|
||||
});
|
||||
window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()});
|
||||
</script>}
|
||||
<script is:inline>document.querySelectorAll('form[data-confirm-message]').forEach(form=>form.addEventListener('submit',event=>{if(!confirm(form.dataset.confirmMessage))event.preventDefault()}));document.querySelectorAll('.entity-tabs button').forEach((button)=>button.addEventListener('click',()=>{document.querySelectorAll('.entity-tabs button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.entity-tab-panel').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-panel="${button.dataset.tab}"]`)?.classList.add('active');}));const hash=location.hash.slice(1);if(hash)document.querySelector(`[data-tab="${hash}"]`)?.click();</script>
|
||||
window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()});
|
||||
</script>
|
||||
}
|
||||
<script is:inline>
|
||||
document.querySelectorAll('form[data-confirm-message]').forEach(form=>form.addEventListener('submit',event=>{if(!confirm(form.dataset.confirmMessage))event.preventDefault()}));
|
||||
function switchIngredientTab(tabName) {
|
||||
document.querySelectorAll('.entity-tabs button').forEach(x=>x.classList.toggle('active', x.dataset.tab === tabName));
|
||||
document.querySelectorAll('.entity-tab-panel').forEach(x=>x.classList.toggle('active', x.dataset.panel === tabName));
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}
|
||||
document.querySelectorAll('.entity-tabs button').forEach((button)=>button.addEventListener('click',()=>{
|
||||
switchIngredientTab(button.dataset.tab);
|
||||
}));
|
||||
const hash=location.hash.slice(1);
|
||||
if(hash) {
|
||||
const target = document.querySelector(`[data-tab="${hash}"]`);
|
||||
if (target) target.click();
|
||||
} else if (window.innerWidth <= 900) {
|
||||
switchIngredientTab('overview');
|
||||
}
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||||
import { databaseProjection, duplicateRecipe, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
|
||||
import { databaseProjection, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
|
||||
import { recipeStructure } from "../../../../lib/database";
|
||||
import RecipeStructureEditor from "../../../../components/RecipeStructureEditor";
|
||||
import RecipeCalculator, { LiveCostValues, LiveNutritionValues } from "../../../../components/RecipeCalculator";
|
||||
@@ -21,16 +21,12 @@ let error: string | undefined;
|
||||
if (Astro.request.method === "POST") {
|
||||
try {
|
||||
const form = await Astro.request.formData();
|
||||
if(form.get("intent")==="media"){
|
||||
const url=String(form.get("url")??"").trim(),mediaType=String(form.get("media_type")??"image"),stepId=String(form.get("step_id")??"").trim()||null;if(!url)throw new Error("Media URL is required.");if(!["image","video"].includes(mediaType))throw new Error("Invalid media type.");
|
||||
const position=(database.prepare("SELECT coalesce(max(position),0)+1 position FROM recipe_media WHERE recipe_id=?").get(id) as any).position;database.prepare("INSERT INTO recipe_media(recipe_id,id,step_id,media_type,url,caption,position) VALUES (?,?,?,?,?,?,?)").run(id,`media_${Date.now()}`,stepId,mediaType,url,String(form.get("caption")??"").trim()||null,position);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
|
||||
}
|
||||
if(form.get("intent")==="additional"){
|
||||
const row=database.prepare("SELECT source_json FROM recipes WHERE id=?").get(id) as any,source=JSON.parse(row.source_json??"{}");
|
||||
const shelfQuantity=Number(form.get("shelf_quantity")),shelfUnit=String(form.get("shelf_unit")??"").trim();
|
||||
if(shelfQuantity>0&&shelfUnit)source.shelf_life={duration:{quantity:shelfQuantity,unit_id:shelfUnit},storage_condition:String(form.get("storage_condition")??"").trim()||undefined};else delete source.shelf_life;
|
||||
const notes=String(form.get("notes")??"").split("\n").map(value=>value.trim()).filter(Boolean);
|
||||
database.prepare("UPDATE recipes SET station=?,cover_media_url=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,String(form.get("cover_media_url")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id);
|
||||
database.prepare("UPDATE recipes SET station=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id);
|
||||
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
|
||||
}
|
||||
if (form.get("intent") === "duplicate") {
|
||||
@@ -117,9 +113,8 @@ const ingredientOptions = (database.prepare("SELECT id, name FROM ingredients WH
|
||||
const recipeOptions = database.prepare("SELECT id, title AS name FROM recipes WHERE deleted_at IS NULL ORDER BY title").all() as Array<{ id: string; name: string }>;
|
||||
const prepActionOptions = database.prepare("SELECT id, name FROM prep_actions ORDER BY name").all() as Array<{ id: string; name: string }>;
|
||||
const recipeConversions=database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
|
||||
const additional=database.prepare("SELECT station,cover_media_url,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
|
||||
const additional=database.prepare("SELECT station,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
|
||||
const additionalSource=JSON.parse(additional.source_json??"{}"),shelfLife=additionalSource.shelf_life;
|
||||
const media=database.prepare("SELECT * FROM recipe_media WHERE recipe_id=? ORDER BY position").all(id) as any[];
|
||||
const projection = databaseProjection(database) as { recipes:Recipe[]; ingredients:Ingredient[]; units:Unit[]; sourceMappings:SourceMapping[]; purchaseItems:PurchaseItem[]; prepActions:PrepAction[] };
|
||||
const domainRecipe = projection.recipes.find((entry) => entry.id === id)!;
|
||||
const recipeMap = new Map(projection.recipes.map((entry) => [entry.id, entry]));
|
||||
@@ -149,42 +144,297 @@ database.close();
|
||||
const categories = JSON.parse(recipe.categories_json).join(", ");
|
||||
const tags = JSON.parse(recipe.tags_json).join(", ");
|
||||
const saved = Astro.url.searchParams.get("saved") === "1";
|
||||
const RECIPE_TAB_ICONS={
|
||||
formula:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
method:"M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14v-2H7zm0-8v2h14V5z",
|
||||
costing:"M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4",
|
||||
equivalencies:"M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z",
|
||||
nutrition:"M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z"
|
||||
};
|
||||
const RECIPE_TAB_VIEWBOX={formula:"0 0 14 16",method:"0 0 24 24",costing:"0 0 24 24",equivalencies:"0 0 24 24",nutrition:"0 0 20 18"};
|
||||
const recipeTabIcon=(name:string)=>`<span class="recipe-tab-icon"><svg viewBox="${RECIPE_TAB_VIEWBOX[name as keyof typeof RECIPE_TAB_VIEWBOX]}" aria-hidden="true"><path fill="currentColor" d="${RECIPE_TAB_ICONS[name as keyof typeof RECIPE_TAB_ICONS]}"/></svg></span>`;
|
||||
---
|
||||
<BaseLayout title={editing?`Edit ${recipe.title}`:recipe.title} immersive>
|
||||
<section class="recipe-detail-shell">
|
||||
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe" />
|
||||
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=recipe">← Recipes</a></p>{editing?<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>:<h1>{recipe.title}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>:<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}>✎ Edit</a>}<details class="detail-actions-menu"><summary aria-label="Recipe actions">⋮</summary><div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div></details></div>}</header>
|
||||
<div class="recipe-workspace-tabs">{editing?<><button class="active" type="button" data-edit-recipe-tab="method">☷ Prep Method</button><button type="button" data-edit-recipe-tab="costing">$ Cost</button><button type="button" data-edit-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-edit-recipe-tab="nutrition">♡ Nutrition</button></>:<><button class="active" type="button" data-recipe-tab="method">☷ Prep Method</button><button type="button" data-recipe-tab="costing">$ Cost</button><button type="button" data-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-recipe-tab="nutrition">♡ Nutrition</button></>}</div>
|
||||
{editing?<><section class="recipe-overview-strip">
|
||||
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
|
||||
<input type="hidden" name="save_version" value={recipe.save_version} />
|
||||
{saved && <div class="success-notice">Changes saved.</div>}
|
||||
{error && <div class="notice">{error}</div>}
|
||||
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}><span>Finished Yield</span><label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label><label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label><input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/><input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>{autoYield&&<small>Calculated from convertible ingredient quantities</small>}</div>
|
||||
<input type="hidden" name="summary" value={recipe.summary??""}/><input type="hidden" name="categories" value={categories}/><input type="hidden" name="tags" value={tags}/>
|
||||
</form>
|
||||
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}><input type="hidden" name="intent" value="auto_yield"/><button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button><span>Auto calculate total yield</span>{autoYield&&<small><b>Revert</b> to original and disable auto calculate</small>}</form>
|
||||
</section>
|
||||
<section id="structure" class="recipe-structure-workspace unified-recipe-editor"><RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/></section>
|
||||
<section id="costing" class="recipe-edit-tab-panel" data-edit-recipe-panel="costing"><LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id} editable/></section>
|
||||
<section id="equivalencies" class="recipe-equivalence-editor recipe-edit-tab-panel" data-edit-recipe-panel="equivalencies"><h2>UoM Equivalency</h2><p>Define how this finished recipe converts between weight, volume, and portions.</p>{recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>)}<form method="post"><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><span>=</span><input name="to_quantity" type="number" min="0.0001" step="any"/><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><input name="notes" placeholder="Notes"/><button name="intent" value="conversion">Add equivalency</button></form></section>
|
||||
<section id="nutrition" class="recipe-edit-nutrition recipe-edit-tab-panel" data-edit-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable saveVersion={recipe.save_version}/></section>
|
||||
<section id="additional" class="recipe-additional-editor"><h2>Additional Details</h2><form id="recipe-additional-form"><label class="cover-media-field"><span>{additional.cover_media_url?"Replace Cover Image":"Add Cover Image"}</span>{additional.cover_media_url&&<img src={additional.cover_media_url} alt=""/>}<input form="recipe-additional-form" name="cover_media_url" type="url" value={additional.cover_media_url??""} placeholder="Paste image URL"/></label><fieldset><legend>Shelf Life</legend><input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/><select name="shelf_unit"><option value="">Unit</option>{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}</select><input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/></fieldset><label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label><label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label></form></section>
|
||||
</>:<><section id="structure" class="recipe-view-workspace"><div class="recipe-view-formula"><RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false} showPercentControls={false}/></div><div class="recipe-view-details"><section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method"><h2>Prep Method <small>{domainRecipe.steps.length}</small></h2><ol>{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=><figure class="step-media">{entry.media_type==="image"?<img src={entry.url} alt={entry.caption??""}/>:<video src={entry.url} controls/>}{entry.caption&&<figcaption>{entry.caption}</figcaption>}</figure>)}</span></li>)}</ol></section><section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies"><h2>UoM Equivalency</h2>{recipeConversions.length?recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>):<p>No recipe-level equivalencies have been defined.</p>}</section><section id="costing" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing"><LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id}/></section><section id="nutrition" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients}/></section></div></section><section class="recipe-additional-view">{additional.cover_media_url&&<figure><img src={additional.cover_media_url} alt="" loading="lazy"/></figure>}<div><h2>Additional details</h2>{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}</div></section></>}
|
||||
<section class="recipe-detail-shell recipe-read-shell">
|
||||
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe">
|
||||
<div class="entity-header-actions">
|
||||
{editing ? (
|
||||
<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>
|
||||
) : !readOnlyMode && (
|
||||
<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
|
||||
)}
|
||||
<details class="detail-actions-menu">
|
||||
<summary aria-label="Recipe actions"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary>
|
||||
<div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div>
|
||||
</details>
|
||||
</div>
|
||||
</DetailUtility>
|
||||
|
||||
<div class="recipe-read-left">
|
||||
<header class="entity-detail-header">
|
||||
<div>
|
||||
{editing ? (
|
||||
<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>
|
||||
) : (
|
||||
<h1>{recipe.title}</h1>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{editing && (
|
||||
<section class="recipe-overview-strip">
|
||||
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
|
||||
<input type="hidden" name="save_version" value={recipe.save_version} />
|
||||
{saved && <div class="success-notice">Changes saved.</div>}
|
||||
{error && <div class="notice">{error}</div>}
|
||||
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}>
|
||||
<span class="yield-title-label">Total Yield</span>
|
||||
<div class="yield-inputs-row">
|
||||
<label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label>
|
||||
<label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label>
|
||||
</div>
|
||||
<input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/>
|
||||
<input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>
|
||||
</div>
|
||||
<input type="hidden" name="summary" value={recipe.summary??""}/>
|
||||
<input type="hidden" name="categories" value={categories}/>
|
||||
<input type="hidden" name="tags" value={tags}/>
|
||||
</form>
|
||||
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}>
|
||||
<input type="hidden" name="intent" value="auto_yield"/>
|
||||
<button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button>
|
||||
<span>Auto calculate total yield</span>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<section id="structure" class="recipe-structure-workspace unified-recipe-editor">
|
||||
<RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/>
|
||||
</section>
|
||||
) : (
|
||||
<section class="recipe-view-formula-pane recipe-tab-panel active" data-recipe-panel="formula">
|
||||
<div class="recipe-view-formula">
|
||||
<RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false} showPercentControls={false}/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="recipe-read-right">
|
||||
<div class="recipe-workspace-tabs recipe-read-tabs">
|
||||
<button class="mobile-only-tab active" type="button" data-recipe-tab="formula"><Fragment set:html={recipeTabIcon("formula")}/><span class="recipe-tab-label">Ingredients</span></button>
|
||||
<button type="button" data-recipe-tab="method"><Fragment set:html={recipeTabIcon("method")}/><span class="recipe-tab-label">Prep Method</span></button>
|
||||
<button type="button" data-recipe-tab="costing"><Fragment set:html={recipeTabIcon("costing")}/><span class="recipe-tab-label">Cost</span></button>
|
||||
<button type="button" data-recipe-tab="equivalencies"><Fragment set:html={recipeTabIcon("equivalencies")}/><span class="recipe-tab-label">UoM Equivalency</span></button>
|
||||
<button type="button" data-recipe-tab="nutrition"><Fragment set:html={recipeTabIcon("nutrition")}/><span class="recipe-tab-label">Nutrition</span></button>
|
||||
</div>
|
||||
|
||||
<section class="recipe-view-details-pane">
|
||||
<div class="recipe-view-details">
|
||||
{editing ? (
|
||||
<section class="recipe-tab-panel active" data-recipe-panel="method">
|
||||
<div id="recipe-method-editor-slot"></div>
|
||||
<section id="additional" class="recipe-additional-editor">
|
||||
<h2>Additional details</h2>
|
||||
<form id="recipe-additional-form">
|
||||
<fieldset>
|
||||
<legend>Shelf Life</legend>
|
||||
<input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/>
|
||||
<select name="shelf_unit">
|
||||
<option value="">Unit</option>
|
||||
{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}
|
||||
</select>
|
||||
<input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/>
|
||||
</fieldset>
|
||||
<label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label>
|
||||
<label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
) : (
|
||||
<section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method">
|
||||
<h2>Prep Method <small>{domainRecipe.steps.length}</small></h2>
|
||||
<ol>
|
||||
{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}</span></li>)}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies">
|
||||
<h2>U of M Equivalency</h2>
|
||||
<p class="uom-equation-title">XX Weight = XX Volume = XX Each</p>
|
||||
<p class="uom-helper-text">
|
||||
If you would like to use this recipe by weight, volume, and even by the portion - you can customize that here.
|
||||
<button class="uom-help-btn" type="button" aria-label="Help on UoM Equivalency">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 16h-2v-2h2v2zm1.07-7.75l-.9.92C12.45 11.9 12 12.5 12 14h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H7c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.04-.42 1.99-1.07 2.75z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div class="uom-toggle-row">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="standard-conversion-toggle" checked />
|
||||
<i></i>
|
||||
</label>
|
||||
<div class="toggle-label-group">
|
||||
<strong>Standard Weight - Volume Conversion</strong>
|
||||
<small>When toggled on, conversions are locked to 8oz = 1 cup. Toggle off to customize.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uom-equation-row">
|
||||
<!-- Weight Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Weight</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-mass-qty"
|
||||
name="mass_conversion"
|
||||
class="uom-qty-input"
|
||||
value={domainRecipe.yield.amount.quantity || 95}
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Weight quantity"
|
||||
/>
|
||||
<select id="uom-mass-unit" name="mass_unit" class="uom-unit-select" aria-label="Weight unit">
|
||||
{units.filter(u => u.dimension === "mass" || (!u.dimension && ["gram", "g", "oz", "ounce", "lb", "pound", "kg"].includes(u.id))).map(u => (
|
||||
<option value={u.id} selected={u.id === (domainRecipe.yield.amount.unit_id || "gram") || u.id === "gram" || u.symbol === "g"}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals Sign -->
|
||||
<div class="uom-equation-separator">=</div>
|
||||
|
||||
<!-- Volume Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Volume</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-volume-qty"
|
||||
name="volume_conversion"
|
||||
class="uom-qty-input"
|
||||
value="1"
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Volume quantity"
|
||||
/>
|
||||
<select id="uom-volume-unit" name="volume_unit" class="uom-unit-select" aria-label="Volume unit">
|
||||
{units.filter(u => u.dimension === "volume" || (!u.dimension && ["cup", "fl_oz", "tbsp", "tsp", "ml", "liter", "l", "gallon", "quart", "pint"].includes(u.id))).map(u => (
|
||||
<option value={u.id} selected={u.id === "cup" || u.symbol === "cup"}>
|
||||
{u.symbol || u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equals Sign -->
|
||||
<div class="uom-equation-separator">=</div>
|
||||
|
||||
<!-- Each Group -->
|
||||
<div class="uom-equation-group">
|
||||
<span class="uom-group-label">Each</span>
|
||||
<div class="uom-box-pair">
|
||||
<input
|
||||
type="number"
|
||||
id="uom-container-qty"
|
||||
name="container_conversion"
|
||||
class="uom-qty-input"
|
||||
value={domainRecipe.yield.servings || 1}
|
||||
placeholder="1"
|
||||
step="any"
|
||||
min="0.0001"
|
||||
aria-label="Each quantity"
|
||||
/>
|
||||
<select id="uom-container-unit" name="container_unit" class="uom-unit-select" aria-label="Each unit">
|
||||
<option value="serving" selected>serving</option>
|
||||
<option value="portion">portion</option>
|
||||
<option value="each">each</option>
|
||||
<option value="ea.">ea.</option>
|
||||
{units.filter(u => u.dimension === "count").map(u => (
|
||||
<option value={u.id}>{u.symbol || u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipeConversions.length > 0 && (
|
||||
<div class="uom-custom-conversions">
|
||||
<h3>Custom Equivalencies</h3>
|
||||
{recipeConversions.map(x => (
|
||||
<div class="uom-custom-row">
|
||||
<strong>{x.from_quantity} {unitMap.get(x.from_unit_id)?.symbol ?? x.from_unit_id}</strong>
|
||||
<span>=</span>
|
||||
<strong>{x.to_quantity} {unitMap.get(x.to_unit_id)?.symbol ?? x.to_unit_id}</strong>
|
||||
{x.notes && <small>{x.notes}</small>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing">
|
||||
<LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id} editable={editing}/>
|
||||
</section>
|
||||
|
||||
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition">
|
||||
<LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable={editing} saveVersion={recipe.save_version}/>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!editing && (
|
||||
<section class="recipe-additional-view">
|
||||
<div>
|
||||
<h2>Additional details</h2>
|
||||
{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}
|
||||
{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}
|
||||
{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{!editing&&<script is:inline>document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.remove('active'));document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-recipe-panel="${button.dataset.recipeTab}"]`)?.classList.add('active');}));const recipeHash=location.hash.slice(1);if(recipeHash)document.querySelector(`[data-recipe-tab="${recipeHash}"]`)?.click();</script>}
|
||||
<script is:inline>
|
||||
function switchRecipeTab(tabName) {
|
||||
document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.toggle('active', x.dataset.recipeTab === tabName));
|
||||
document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.toggle('active', x.dataset.recipePanel === tabName));
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
}
|
||||
document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{
|
||||
switchRecipeTab(button.dataset.recipeTab);
|
||||
}));
|
||||
const recipeHash=location.hash.slice(1);
|
||||
if(recipeHash) {
|
||||
const targetTab = document.querySelector(`[data-recipe-tab="${recipeHash}"]`);
|
||||
if (targetTab) {
|
||||
targetTab.click();
|
||||
}
|
||||
} else if (window.innerWidth <= 900) {
|
||||
switchRecipeTab('formula');
|
||||
}
|
||||
</script>
|
||||
{editing&&<script is:inline>
|
||||
let recipeDirty=false;
|
||||
const setRecipeDirty=(value=true)=>{recipeDirty=value;document.querySelector('#recipe-done')?.classList.toggle('dirty',value)};
|
||||
const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()};
|
||||
const setupRecipeEditor=async()=>{const method=document.querySelector('.method-editor');if(!method||method.dataset.tabsReady)return;method.dataset.tabsReady='1';const coverSlot=method.querySelector('#recipe-cover-slot'),additionalSlot=method.querySelector('#recipe-additional-slot'),panelSlot=method.querySelector('#recipe-tab-panel-slot'),panels=[...document.querySelectorAll('[data-edit-recipe-panel]')],additional=document.querySelector('#additional'),cover=additional?.querySelector('.cover-media-field'),prepChildren=[method.querySelector(':scope > h2'),method.querySelector(':scope > ol'),method.querySelector(':scope > button')].filter(Boolean);for(let frame=0;frame<120&&panels.some(panel=>[...panel.querySelectorAll('astro-island')].some(island=>island.hasAttribute('ssr')));frame++)await new Promise(requestAnimationFrame);if(cover&&coverSlot)coverSlot.append(cover);panels.forEach(panel=>{panel.hidden=true;panelSlot?.append(panel)});if(additional&&additionalSlot)additionalSlot.append(additional);document.querySelectorAll('[data-edit-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{const selected=button.dataset.editRecipeTab;document.querySelectorAll('[data-edit-recipe-tab]').forEach(tab=>tab.classList.toggle('active',tab===button));prepChildren.forEach(child=>child.hidden=selected!=='method');if(coverSlot)coverSlot.hidden=selected!=='method';if(additionalSlot)additionalSlot.hidden=selected!=='method';if(panelSlot)panelSlot.hidden=selected==='method';panels.forEach(panel=>panel.hidden=panel.dataset.editRecipePanel!==selected)}));};
|
||||
document.addEventListener('recipe:editor-ready',setupRecipeEditor);
|
||||
document.addEventListener('recipe:dirty',event=>setRecipeDirty(Boolean(event.detail)));
|
||||
document.querySelector('#recipe-details-form')?.addEventListener('input',()=>setRecipeDirty());
|
||||
document.querySelector('#recipe-additional-form')?.addEventListener('input',()=>setRecipeDirty());
|
||||
document.querySelector('#recipe-details-form')?.addEventListener('submit',event=>event.preventDefault());
|
||||
window.addEventListener('beforeunload',event=>{if(recipeDirty)event.preventDefault()});
|
||||
setTimeout(()=>{setupRecipeEditor();setupTagEditor()},0);
|
||||
setTimeout(()=>setupTagEditor(),0);
|
||||
document.querySelector('#recipe-done')?.addEventListener('click',async(event)=>{const button=event.currentTarget;button.disabled=true;button.textContent='Saving…';const saved=await new Promise(resolve=>document.dispatchEvent(new CustomEvent('recipe:save-structure',{detail:{complete:resolve}})));if(saved){recipeDirty=false;location.href=button.dataset.viewUrl}else{button.disabled=false;button.textContent='✓ Done';}});
|
||||
</script>}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -20,9 +20,43 @@ const resolvedHref=sectionHref??inferred.href;
|
||||
<li><a href="/app/">Home</a></li>
|
||||
{resolvedSection&&<li aria-current="page"><a href={resolvedHref}>{resolvedSection}</a></li>}
|
||||
</ol>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="detail-tools">
|
||||
<form action="/app/" role="search"><input name="q" type="search" placeholder="Search" aria-label="Search all items"/></form>
|
||||
{!readOnlyMode&&<details class="detail-new-menu"><summary><span class="new-trigger-plus" aria-hidden="true">+</span><span>New</span></summary><div><a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a><a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a></div></details>}
|
||||
<form action="/app/" role="search" class="detail-search-form">
|
||||
<span class="search-input-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
</span>
|
||||
<input name="q" type="text" placeholder="Search" aria-label="Search all items" class="search-field" autocomplete="off"/>
|
||||
<button type="button" class="search-clear-btn" aria-label="Clear search" title="Clear search">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{!readOnlyMode&&<details class="detail-new-menu">
|
||||
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span>New</span></summary>
|
||||
<div>
|
||||
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" style="transform: scale(1.1, 1.1) translate(4px, 3.5px);"/></svg></span><strong>Recipe</strong></a>
|
||||
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765C2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z" style="transform: scale(1.1, 1.1) translate(3.5px, 2.5px);"/></svg></span><strong>Recipe book</strong></a>
|
||||
</div>
|
||||
</details>}
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
document.querySelectorAll('.detail-search-form').forEach((formEl) => {
|
||||
const form = formEl as HTMLFormElement;
|
||||
const input = form.querySelector('.search-field') as HTMLInputElement | null;
|
||||
const clearBtn = form.querySelector('.search-clear-btn') as HTMLButtonElement | null;
|
||||
if (!input || !clearBtn) return;
|
||||
const sync = () => {
|
||||
if (input.value.trim().length > 0) form.classList.add('has-value');
|
||||
else form.classList.remove('has-value');
|
||||
};
|
||||
input.addEventListener('input', sync);
|
||||
clearBtn.addEventListener('click', () => {
|
||||
input.value = '';
|
||||
sync();
|
||||
input.focus();
|
||||
});
|
||||
sync();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useRef,useState } from "preact/hooks";
|
||||
|
||||
export type DirectoryRow = {
|
||||
id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; icon:string;
|
||||
id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; detail?:string;
|
||||
};
|
||||
type Props={ rows:DirectoryRow[]; entityType:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean };
|
||||
const TYPE_ICONS:Record<DirectoryRow["kind"],string> = {
|
||||
recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z",
|
||||
ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
|
||||
book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z",
|
||||
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"
|
||||
};
|
||||
const TYPE_ICON_TRANSFORMS:Record<DirectoryRow["kind"],string|undefined> = {
|
||||
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
|
||||
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
|
||||
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
|
||||
purchase:undefined
|
||||
};
|
||||
type Props={ rows:DirectoryRow[]; entityType?:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean };
|
||||
|
||||
export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=false}:Props) {
|
||||
const [selected,setSelected]=useState<string[]>([]),[deleting,setDeleting]=useState(false),[error,setError]=useState("");
|
||||
@@ -12,34 +24,59 @@ export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=f
|
||||
const allSelected=rows.length>0&&selected.length===rows.length;
|
||||
const toggle=(id:string)=>setSelected(current=>current.includes(id)?current.filter(value=>value!==id):[...current,id]);
|
||||
const requestDelete=(ids:string[])=>{if(!ids.length)return;setPendingDelete(ids);dialog.current?.showModal();};
|
||||
const kindById=new Map(rows.map(row=>[row.id,row.kind]));
|
||||
const remove=async()=>{
|
||||
const ids=pendingDelete;
|
||||
if(!ids.length)return;
|
||||
setDeleting(true);setError("");
|
||||
const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:entityType,ids})});
|
||||
const result=await response.json();
|
||||
if(!response.ok){setError(result.error??"Unable to delete selection.");setDeleting(false);dialog.current?.close();return;}
|
||||
const groups=new Map<DirectoryRow["kind"],string[]>();
|
||||
for(const id of ids){const kind=kindById.get(id)??entityType??"recipe";if(!groups.has(kind))groups.set(kind,[]);groups.get(kind)!.push(id);}
|
||||
try{
|
||||
for(const [kind,groupIds] of groups){
|
||||
const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:kind,ids:groupIds})});
|
||||
const result=await response.json();
|
||||
if(!response.ok){setError(result.error??"Unable to delete selection.");dialog.current?.close();return;}
|
||||
}
|
||||
} finally { setDeleting(false); }
|
||||
location.reload();
|
||||
};
|
||||
return <section class={`entity-directory-table${readOnly?" read-only":""}`}>
|
||||
{!readOnly&&<div class="entity-directory-toolbar entity-directory-head">
|
||||
<input aria-label={`Select all ${entityType} items`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
|
||||
<span>Type</span>
|
||||
<span>Name</span>
|
||||
<div class="entity-directory-toolbar-actions">{selected.length>0&&<><strong>{selected.length} selected</strong><button class="bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)}>⌫ Delete</button><button type="button" onClick={()=>setSelected([])}>Clear</button></>}</div>
|
||||
{!readOnly&&<div class={`entity-directory-toolbar entity-directory-head${selected.length>0?" has-selection":""}`}>
|
||||
<input aria-label={`Select all ${entityType??"items"}`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
|
||||
{selected.length>0 ? (
|
||||
<div class="entity-directory-selection-bar">
|
||||
<strong class="selection-count">{selected.length} Selected</strong>
|
||||
<div class="entity-directory-toolbar-actions">
|
||||
<button class="bulk-action-btn bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)} title="Delete selected" aria-label="Delete selected">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
<button class="bulk-action-btn bulk-clear" type="button" onClick={()=>setSelected([])} title="Clear selection" aria-label="Clear selection">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span>Type</span>
|
||||
<span>Name</span>
|
||||
<div class="entity-directory-toolbar-actions"></div>
|
||||
</>
|
||||
)}
|
||||
</div>}
|
||||
{error&&<p class="directory-error">{error}</p>}
|
||||
<div>{rows.map(row=><div class={`entity-directory-row${selected.includes(row.id)?" selected":""}`}>
|
||||
{!readOnly&&<input aria-label={`Select ${row.name}`} type="checkbox" checked={selected.includes(row.id)} onChange={()=>toggle(row.id)}/>}
|
||||
<span class={`workspace-pill-icon ${row.kind}`}>{row.icon}</span>
|
||||
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong></a>:<strong>{row.name}</strong>}</span>
|
||||
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}>⋮</summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
|
||||
<span class={`workspace-pill-icon ${row.kind}`}><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[row.kind]} style={TYPE_ICON_TRANSFORMS[row.kind]?{transform:TYPE_ICON_TRANSFORMS[row.kind]}:undefined}/></svg></span>
|
||||
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong>{row.detail&&<small>{row.detail}</small>}</a>:<><strong>{row.name}</strong>{row.detail&&<small>{row.detail}</small>}</>}</span>
|
||||
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
|
||||
</div>)}</div>
|
||||
{rows.length===0&&<div class="empty-state">{emptyMessage}</div>}
|
||||
{!readOnly&&<dialog class="delete-confirmation" ref={dialog} onClose={()=>{if(!deleting)setPendingDelete([]);}}>
|
||||
<form method="dialog"><button class="dialog-close" aria-label="Close">×</button></form>
|
||||
<h2>Delete {pendingDelete.length===1?"item":`${pendingDelete.length} items`}?</h2>
|
||||
<p>This permanently removes the selected {pendingDelete.length===1?entityType:`${entityType} items`}. This action cannot be undone.</p>
|
||||
<p>This permanently removes the selected {pendingDelete.length===1?(entityType??"item"):`${pendingDelete.length} ${entityType??"items"}`}. This action cannot be undone.</p>
|
||||
<div><form method="dialog"><button disabled={deleting}>Cancel</button></form><button class="confirm-delete" type="button" disabled={deleting} onClick={remove}>{deleting?"Deleting…":"Delete"}</button></div>
|
||||
</dialog>}
|
||||
</section>;
|
||||
|
||||
@@ -49,7 +49,7 @@ function convertItem(quantity: number, fromUnitId: string, toUnitId: string, ite
|
||||
|
||||
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [] }: Props) {
|
||||
const [factor, setFactor] = useState(1);
|
||||
const [calculatePercent,setCalculatePercent]=useState(true);
|
||||
const [calculatePercent,setCalculatePercent]=useState(showPercentControls);
|
||||
const [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard");
|
||||
const [yieldDisplayUnitId, setYieldDisplayUnitId] = useState(yieldUnitId);
|
||||
const [lineUnits, setLineUnits] = useState<Record<string, string>>({});
|
||||
@@ -78,29 +78,52 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
return (
|
||||
<section class="calculator" aria-labelledby="formula-heading">
|
||||
<div class="calculator-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Scalable formula</p>
|
||||
<h2 id="formula-heading">Ingredients</h2>
|
||||
</div>
|
||||
<label class="basis-input">
|
||||
<span>Batch multiplier</span>
|
||||
<span class="input-with-unit recipe-scale-control batch-size-control">
|
||||
<label class="basis-input batch-multiplier-field">
|
||||
<span class="scale-label">Batch:</span>
|
||||
<span class="recipe-scale-control batch-size-control">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Batch multiplier"
|
||||
value={roundForDisplay(factor)}
|
||||
onInput={(event) => setFactor(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
<span class="scale-suffix">x</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="basis-input">
|
||||
<span>Finished yield</span>
|
||||
<span class="quantity-control recipe-scale-control"><input type="number" min="0" step="any" value={roundForDisplay(scaledYield)} onInput={(event) => changeYield(Number(event.currentTarget.value))}/><select aria-label="Yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId(event.currentTarget.value)}>{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span>
|
||||
<label class="basis-input finished-yield-field">
|
||||
<span class="scale-label">Yield:</span>
|
||||
<span class="recipe-scale-control quantity-control">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Finished yield quantity"
|
||||
value={roundForDisplay(scaledYield)}
|
||||
onInput={(event) => changeYield(Number((event.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
<select aria-label="Finished yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId((event.currentTarget as HTMLSelectElement).value)}>
|
||||
{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
<div class="calculator-percent-controls">{showPercentControls&&calculatePercent&&<span class="percent-mode"><button class={percentMode==="standard"?"active":""} onClick={()=>setPercentMode("standard")}>Standard %</button><button class={percentMode==="bakers"?"active":""} onClick={()=>setPercentMode("bakers")}>Baker's %</button></span>}{showPercentControls&&<label class="calculate-toggle"><span>Calculate %</span><input type="checkbox" checked={calculatePercent} onChange={(event)=>setCalculatePercent(event.currentTarget.checked)}/><i></i></label>}</div>
|
||||
{showPercentControls && (
|
||||
<div class="calculator-percent-controls">
|
||||
{calculatePercent && (
|
||||
<span class="percent-mode">
|
||||
<button type="button" class={percentMode === "standard" ? "active" : ""} onClick={() => setPercentMode("standard")}>Standard %</button>
|
||||
<button type="button" class={percentMode === "bakers" ? "active" : ""} onClick={() => setPercentMode("bakers")}>Baker's %</button>
|
||||
</span>
|
||||
)}
|
||||
<label class="calculate-toggle">
|
||||
<span>Calculate %</span>
|
||||
<input type="checkbox" checked={calculatePercent} onChange={(event) => setCalculatePercent((event.currentTarget as HTMLInputElement).checked)} />
|
||||
<i></i>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p class="scale-relationship">1× produces {number(yieldQuantity)} {units[yieldUnitId]?.symbol??yieldUnitId} finished yield. Changing the multiplier, finished yield, or any ingredient amount scales the entire recipe.</p>
|
||||
|
||||
{calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&<section class="bakers-base-summary"><strong>Base</strong><div>{baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return <p><span><input aria-label={`${item.label} base quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event)=>{const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/><select aria-label={`${item.label} base unit`} value={displayUnitId} onChange={(event)=>setLineUnits((current)=>({...current,[item.id]:event.currentTarget.value}))}>{itemUnits(item,units).map(unit=><option value={unit.id}>{unit.symbol}</option>)}</select></span><a href={item.href}>{item.label}</a></p>})}</div></section>}
|
||||
{components.map((component) => (
|
||||
@@ -108,7 +131,7 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
{components.length > 1 && <h3>{component.name}</h3>}
|
||||
<div class="table-wrap">
|
||||
<table class="recipe-ingredients">
|
||||
<thead><tr><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}<th>Weight</th></tr></thead>
|
||||
<thead><tr><th>Amount</th><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}</tr></thead>
|
||||
<tbody>
|
||||
{component.items.filter(item=>percentMode!=="bakers"||!calculatePercent||!item.basisMember).map((item) => {
|
||||
const displayUnitId = lineUnits[item.id] ?? item.amount.unit_id;
|
||||
@@ -120,13 +143,13 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
|
||||
};
|
||||
return (
|
||||
<tr key={item.id} class={item.basisMember ? "basis-row" : ""}>
|
||||
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} style={{width:`${(compatibleUnits.find((unit) => unit.id === displayUnitId)?.symbol ?? displayUnitId).length + 0.75}ch`}} onChange={(event) => { const next = event.currentTarget.value; try { convertItem(item.amount.quantity * validFactor, item.amount.unit_id, next, item, units); } catch { event.currentTarget.value = displayUnitId; return; } setLineUnits((current) => ({ ...current, [item.id]: next })); }}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
|
||||
<td>
|
||||
<span class="calculator-ingredient-name">{item.href ? <a href={item.href}>{item.label}</a> : item.label}{item.attention&&<span class="ingredient-attention-icon" role="img" aria-label={item.attentionMessage??"Needs attention"} title={item.attentionMessage??"Needs attention"}>!</span>}</span>
|
||||
<span class="calculator-ingredient-name">{item.href ? <a href={item.href}>{item.label}</a> : item.label}{item.attention&&<span class="ingredient-attention-icon" role="img" aria-label={item.attentionMessage??"Needs attention"} title={item.attentionMessage??"Needs attention"}><svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg></span>}</span>
|
||||
{item.optional && <span class="muted"> optional</span>}
|
||||
{item.notes && <small>{item.notes}</small>}
|
||||
</td>
|
||||
{calculatePercent&&<td>{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
|
||||
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} onChange={(event) => setLineUnits((current) => ({ ...current, [item.id]: event.currentTarget.value }))}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
|
||||
{calculatePercent&&<td class="recipe-percent-cell">{itemWeight(item)==null||percentageBase<=0?"—":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -176,7 +199,7 @@ function CostLedgerLine({ line, factor, currency, editable, expanded }: { line:C
|
||||
const body=<>
|
||||
<span class={`cost-subject-icon ${line.kind}`}>{line.kind==="recipe"?"R":"●"}</span>
|
||||
<a href={href}>{line.name}</a>
|
||||
{line.completeness<1&&<span class="cost-attention" title="Cost information is incomplete">!</span>}
|
||||
{line.completeness<1&&<span class="cost-attention" title="Cost information is incomplete" aria-label="Cost information is incomplete"><svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg></span>}
|
||||
<span class="cost-line-value">{line.cost!=null?money.format(line.cost*factor):line.kind==="ingredient"&&editable?<a href={href}>Add cost ✎</a>:"—"}</span>
|
||||
</>;
|
||||
if(line.purchase||line.children?.length)return <details class="cost-ledger-line" open={expanded}><summary>{body}</summary><div class="cost-line-detail">{line.purchase?<><div><small>Purchase item name</small><strong>{line.purchase.name}</strong></div><div><small>Purchase cost</small><strong>{money.format(line.purchase.price)}</strong></div><div><small>Purchase unit</small><strong>{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}</strong></div><div><small>Date added</small><strong>{line.purchase.effectiveAt}</strong></div><div><small>Item ID #</small><strong>{line.purchase.sku??"—"}</strong></div><div><small>Vendor</small><strong>{line.purchase.supplier??"—"}</strong></div></>:<p>No usable purchase cost is available.</p>}{line.children?.length?<div class="cost-child-lines">{line.children.map(child=><CostLedgerLine line={child} factor={factor} currency={currency} editable={editable} expanded={expanded}/>)}</div>:null}</div></details>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { Fragment } from "preact";
|
||||
import { createPortal } from "preact/compat";
|
||||
import type { RecipeStructure } from "../lib/database";
|
||||
import { percentage, targetWeight } from "../lib/percentages";
|
||||
|
||||
@@ -40,6 +42,11 @@ export default function RecipeStructureEditor({
|
||||
const [prepText, setPrepText] = useState("");
|
||||
const [prepError, setPrepError] = useState("");
|
||||
const [pendingIngredients, setPendingIngredients] = useState<Option[]>([]);
|
||||
const [methodTarget, setMethodTarget] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMethodTarget(document.getElementById("recipe-method-editor-slot"));
|
||||
}, []);
|
||||
const [baseline, setBaseline] = useState(JSON.stringify(initial));
|
||||
const [state, setState] = useState<"idle" | "saving" | "saved" | "error">(
|
||||
"idle",
|
||||
@@ -448,70 +455,98 @@ export default function RecipeStructureEditor({
|
||||
{autoYield && unconvertedYieldItems() > 0 && (
|
||||
<p class="auto-yield-warning">Auto yield excludes {unconvertedYieldItems()} ingredient {unconvertedYieldItems() === 1 ? "amount" : "amounts"} without a weight equivalency.</p>
|
||||
)}
|
||||
{data.components.map((component, componentIndex) => (
|
||||
<fieldset class={`component-editor${dragging?.kind === "component" && dragging.index === componentIndex ? " dragging" : ""}`} key={component.id} onDragOver={(event) => event.preventDefault()} onDrop={() => {
|
||||
if (dragging?.kind === "component") setData((current) => ({ ...current, components: move(current.components, dragging.index, componentIndex) }));
|
||||
setDragging(undefined);
|
||||
}}>
|
||||
<legend>Ingredient header {componentIndex + 1}</legend>
|
||||
<div class="editor-inline">
|
||||
<input
|
||||
aria-label="Component name"
|
||||
value={component.name}
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
name: event.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
class="remove-row"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: current.components.filter(
|
||||
(_, index) => index !== componentIndex,
|
||||
),
|
||||
}))
|
||||
}
|
||||
disabled={data.components.length === 1}
|
||||
>
|
||||
⊖
|
||||
</button>
|
||||
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={() => setDragging({ kind: "component", index: componentIndex })}>⠿</span>
|
||||
</div>
|
||||
{(component.notes ?? []).map((note, noteIndex) => (
|
||||
<div class="component-note-row">
|
||||
<input
|
||||
class="component-notes"
|
||||
aria-label={`${component.name} note ${noteIndex + 1}`}
|
||||
value={note}
|
||||
placeholder="Add note"
|
||||
onInput={(event) => updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
notes: (value.notes ?? []).map((entry, index) => index === noteIndex ? event.currentTarget.value : entry),
|
||||
}))}
|
||||
/>
|
||||
<button class="remove-row" aria-label="Remove note" onClick={() => updateComponent(componentIndex, (value) => ({ ...value, notes: (value.notes ?? []).filter((_, index) => index !== noteIndex) }))}>⊖</button>
|
||||
</div>
|
||||
))}
|
||||
<div class="table-wrap">
|
||||
<table class={`editor-items${calculatePercent && percentMode === "bakers" ? " bakers-mode" : ""}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{calculatePercent && percentMode === "bakers" && (
|
||||
<th class="base-column">Base</th>
|
||||
)}
|
||||
{calculatePercent && <th class="percent-column">%</th>}
|
||||
<th class="quantity-column">Qty</th>
|
||||
<th class="unit-column">Unit</th>
|
||||
<th class="entity-column">Ingredient / Recipe</th>
|
||||
<th class="notes-column">Notes</th>
|
||||
<th class="actions-column"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div class="table-wrap">
|
||||
<table class={`editor-items${calculatePercent && percentMode === "bakers" ? " bakers-mode" : ""}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="quantity-column">Qty</th>
|
||||
<th class="unit-column">Unit</th>
|
||||
<th class="entity-column">Ingredient / Recipe</th>
|
||||
<th class="notes-column">Notes</th>
|
||||
{calculatePercent && percentMode === "bakers" && (
|
||||
<th class="base-column">Base</th>
|
||||
)}
|
||||
{calculatePercent && <th class="percent-column">%</th>}
|
||||
<th class="actions-column"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.components.map((component, componentIndex) => (
|
||||
<Fragment key={component.id}>
|
||||
{(data.components.length > 1 || Boolean(component.name?.trim())) && (
|
||||
<tr
|
||||
class={`component-header-row${dragging?.kind === "component" && dragging.index === componentIndex ? " dragging" : ""}`}
|
||||
key={`header-${component.id}`}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (dragging?.kind === "component") setData((current) => ({ ...current, components: move(current.components, dragging.index, componentIndex) }));
|
||||
setDragging(undefined);
|
||||
}}
|
||||
>
|
||||
<td colSpan={calculatePercent ? (percentMode === "bakers" ? 7 : 6) : 5}>
|
||||
<div class="component-header-content">
|
||||
<input
|
||||
class="component-name-input"
|
||||
aria-label="Component name"
|
||||
placeholder="Edit Header"
|
||||
value={component.name}
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
name: event.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div class="component-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-component-btn"
|
||||
title="Delete section"
|
||||
aria-label="Delete section"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: current.components.filter(
|
||||
(_, index) => index !== componentIndex,
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"/></svg>
|
||||
</button>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder section"
|
||||
aria-label="Drag to reorder section"
|
||||
draggable
|
||||
onDragStart={() => setDragging({ kind: "component", index: componentIndex })}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path fill="currentColor" d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(component.notes ?? []).map((note, noteIndex) => (
|
||||
<tr class="component-note-row" key={`note-${component.id}-${noteIndex}`}>
|
||||
<td colSpan={calculatePercent ? (percentMode === "bakers" ? 7 : 6) : 5}>
|
||||
<div class="component-note-content">
|
||||
<input
|
||||
class="component-notes"
|
||||
aria-label={`${component.name} note ${noteIndex + 1}`}
|
||||
value={note}
|
||||
placeholder="Add section note"
|
||||
onInput={(event) => updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
notes: (value.notes ?? []).map((entry, index) => index === noteIndex ? event.currentTarget.value : entry),
|
||||
}))}
|
||||
/>
|
||||
<button class="remove-row" aria-label="Remove note" onClick={() => updateComponent(componentIndex, (value) => ({ ...value, notes: (value.notes ?? []).filter((_, index) => index !== noteIndex) }))}>✕</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{component.items.map((item, itemIndex) => {
|
||||
const isPending = Boolean(
|
||||
item.ingredient_id &&
|
||||
@@ -534,16 +569,121 @@ export default function RecipeStructureEditor({
|
||||
const shownChoices = choices.filter((entry) => !typedValue.trim() || [entry.name, ...(entry.aliases ?? [])].some((name) => normal(name).includes(normal(typedValue)))).slice(0, 10);
|
||||
const hasUncommittedInput = normal(typedValue) !== normal(label ?? "");
|
||||
const canCreateInput = Boolean(typedValue.trim()) && !exactMatch;
|
||||
const isSubrecipe = Boolean(item.subrecipe_id);
|
||||
return (
|
||||
<tr class={`${dragging?.kind === "item" && dragging.component === componentIndex && dragging.index === itemIndex ? "dragging" : ""}${isPending ? " pending-ingredient-row" : ""}`} key={item.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
|
||||
<tr class={`${dragging?.kind === "item" && dragging.component === componentIndex && dragging.index === itemIndex ? "dragging" : ""}${isPending ? " pending-ingredient-row" : ""}${isSubrecipe ? " is-subrecipe-row" : ""}`} key={item.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
|
||||
event.stopPropagation();
|
||||
if (dragging?.kind === "item" && dragging.component === componentIndex) updateComponent(componentIndex, (value) => ({ ...value, items: move(value.items, dragging.index, itemIndex) }));
|
||||
setDragging(undefined);
|
||||
}}>
|
||||
<td class="quantity-column">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Quantity"
|
||||
value={item.quantity}
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? {
|
||||
...line,
|
||||
quantity: Number(
|
||||
event.currentTarget.value,
|
||||
),
|
||||
}
|
||||
: line,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td class="unit-column">
|
||||
<select
|
||||
aria-label="Unit"
|
||||
value={item.unit_id}
|
||||
onChange={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? {
|
||||
...line,
|
||||
unit_id: event.currentTarget.value,
|
||||
}
|
||||
: line,
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<option value={unit.id}>
|
||||
{unit.symbol || unit.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td class="entity-column">
|
||||
<div class={`row-entity-combobox${hasUncommittedInput ? " unmatched" : ""}${isSubrecipe ? " is-subrecipe" : ""}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={typedValue}
|
||||
placeholder="Ingredient or Recipe"
|
||||
aria-label={`Ingredient or recipe for row ${itemIndex + 1}`}
|
||||
aria-expanded={focusedItem === item.id}
|
||||
aria-autocomplete="list"
|
||||
onFocus={(event) => { setFocusedItem(item.id); event.currentTarget.select(); }}
|
||||
onBlur={() => window.setTimeout(() => setFocusedItem((current) => current === item.id ? undefined : current), 120)}
|
||||
onInput={(event) => setRowQuery((current) => ({ ...current, [item.id]: event.currentTarget.value }))}
|
||||
/>
|
||||
{isPending && (
|
||||
<span class="ingredient-attention-icon" aria-label="New unmatched ingredient" title="New ingredient; details can be completed after saving">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
|
||||
<path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{focusedItem === item.id && (
|
||||
<div class="row-entity-results" role="listbox">
|
||||
{shownChoices.map((entry) => (
|
||||
<button type="button" role="option" onMouseDown={(event) => event.preventDefault()} onClick={() => replaceLineReference(componentIndex, itemIndex, entry.kind, entry.id, entry.name)}>
|
||||
<strong>{entry.name}</strong><small class={`badge-${entry.kind}`}>{entry.kind === "recipe" ? "Recipe" : "Ingredient"}</small>
|
||||
</button>
|
||||
))}
|
||||
{canCreateInput && (
|
||||
<div class="row-create-actions">
|
||||
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => createPendingRowIngredient(componentIndex, itemIndex, typedValue)}>+ Create “{typedValue}”</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPending && <small class="pending-ingredient-help">New ingredient · saved automatically</small>}
|
||||
</td>
|
||||
<td class="notes-column">
|
||||
<input
|
||||
class="line-notes"
|
||||
aria-label={`${label} notes`}
|
||||
value={item.notes ?? ""}
|
||||
placeholder="Add Notes"
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? { ...line, notes: event.currentTarget.value }
|
||||
: line,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
{calculatePercent && percentMode === "bakers" && (
|
||||
<td class="base-column">
|
||||
<input
|
||||
aria-label={`Use ${label} as a Baker's percentage base`}
|
||||
aria-label={`${label} baker's base member`}
|
||||
type="checkbox"
|
||||
checked={item.basis_member}
|
||||
onChange={(event) =>
|
||||
@@ -592,106 +732,13 @@ export default function RecipeStructureEditor({
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td class="quantity-column">
|
||||
<input
|
||||
type="number"
|
||||
min="0.0001"
|
||||
step="any"
|
||||
value={item.quantity}
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? {
|
||||
...line,
|
||||
quantity: Number(
|
||||
event.currentTarget.value,
|
||||
),
|
||||
}
|
||||
: line,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td class="unit-column">
|
||||
<select
|
||||
value={item.unit_id}
|
||||
onChange={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? {
|
||||
...line,
|
||||
unit_id: event.currentTarget.value,
|
||||
}
|
||||
: line,
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<option value={unit.id}>
|
||||
{unit.name} ({unit.symbol})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td class="entity-column">
|
||||
<div class={`row-entity-combobox${hasUncommittedInput ? " unmatched" : ""}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={typedValue}
|
||||
aria-label={`Ingredient or recipe for row ${itemIndex + 1}`}
|
||||
aria-expanded={focusedItem === item.id}
|
||||
aria-autocomplete="list"
|
||||
onFocus={(event) => { setFocusedItem(item.id); event.currentTarget.select(); }}
|
||||
onBlur={() => window.setTimeout(() => setFocusedItem((current) => current === item.id ? undefined : current), 120)}
|
||||
onInput={(event) => setRowQuery((current) => ({ ...current, [item.id]: event.currentTarget.value }))}
|
||||
/>
|
||||
{isPending && <span class="ingredient-attention-icon" aria-label="New unmatched ingredient" title="New ingredient; details can be completed after saving">!</span>}
|
||||
{focusedItem === item.id && (
|
||||
<div class="row-entity-results" role="listbox">
|
||||
{shownChoices.map((entry) => (
|
||||
<button type="button" role="option" onMouseDown={(event) => event.preventDefault()} onClick={() => replaceLineReference(componentIndex, itemIndex, entry.kind, entry.id, entry.name)}>
|
||||
<strong>{entry.name}</strong><small>{entry.kind === "recipe" ? "Recipe" : "Ingredient"}</small>
|
||||
</button>
|
||||
))}
|
||||
{canCreateInput && (
|
||||
<div class="row-create-actions">
|
||||
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => createPendingRowIngredient(componentIndex, itemIndex, typedValue)}>Create New Ingredient</button>
|
||||
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => createPendingRowIngredient(componentIndex, itemIndex, typedValue)}>Create Ingredient w/ Prep Action</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPending && <small class="pending-ingredient-help">New ingredient · created on save</small>}
|
||||
</td>
|
||||
<td class="notes-column">
|
||||
<input
|
||||
class="line-notes"
|
||||
aria-label={`${label} notes`}
|
||||
value={item.notes ?? ""}
|
||||
placeholder="Add notes"
|
||||
onInput={(event) =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
items: value.items.map((line, index) =>
|
||||
index === itemIndex
|
||||
? { ...line, notes: event.currentTarget.value }
|
||||
: line,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td class="actions-column">
|
||||
<span class="row-actions">
|
||||
<div class="row-actions-group">
|
||||
<button
|
||||
class="remove-row"
|
||||
type="button"
|
||||
class="remove-row-btn"
|
||||
title="Delete"
|
||||
aria-label="Delete ingredient"
|
||||
onClick={() =>
|
||||
updateComponent(componentIndex, (value) => ({
|
||||
...value,
|
||||
@@ -701,109 +748,158 @@ export default function RecipeStructureEditor({
|
||||
}))
|
||||
}
|
||||
>
|
||||
⊖
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"/></svg>
|
||||
</button>
|
||||
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={(event) => { event.stopPropagation(); setDragging({ kind: "item", component: componentIndex, index: itemIndex }); }}>⠿</span>
|
||||
</span>
|
||||
<span
|
||||
class="drag-handle"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag to reorder"
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.stopPropagation();
|
||||
setDragging({
|
||||
kind: "item",
|
||||
component: componentIndex,
|
||||
index: itemIndex,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path fill="currentColor" d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="add-line">
|
||||
<div class="recipe-item-search">
|
||||
<input
|
||||
value={query[component.id] ?? ""}
|
||||
placeholder="Type an ingredient or recipe"
|
||||
aria-label={`Search items for ${component.name}`}
|
||||
onInput={(event) => {
|
||||
const value=event.currentTarget.value;
|
||||
setQuery((current) => ({ ...current, [component.id]: value }));
|
||||
}}
|
||||
/>
|
||||
{(query[component.id]?.trim() ?? "") && (
|
||||
<div class="recipe-item-results">
|
||||
{[
|
||||
...[...ingredients,...pendingIngredients].map((item) => ({...item,kind:"ingredient" as const})),
|
||||
...recipes.filter((item) => item.id !== recipeId).map((item) => ({...item,kind:"recipe" as const})),
|
||||
].filter((item) => [item.name,...(item.aliases??[])].some((name) => name.toLowerCase().includes(query[component.id].trim().toLowerCase()))).slice(0,8).map((item) => (
|
||||
<button onClick={() => {
|
||||
addLine(componentIndex, `${item.kind}:${item.id}`);
|
||||
}}><strong>{item.name}</strong><small>{item.kind === "recipe" ? "Recipe" : "Ingredient"}</small></button>
|
||||
))}
|
||||
{!ingredients.some((item) => normal(item.name) === normal(query[component.id])) &&
|
||||
!pendingIngredients.some((item) => normal(item.name) === normal(query[component.id])) && (
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="quick-add-bar">
|
||||
<div class="quick-add-search">
|
||||
<input
|
||||
value={query[data.components[data.components.length - 1]?.id ?? data.components[0]?.id ?? ""] ?? ""}
|
||||
placeholder="1cup onion sliced"
|
||||
aria-label="Add ingredient or recipe"
|
||||
onInput={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
const activeId = data.components[data.components.length - 1]?.id ?? data.components[0]?.id;
|
||||
if (activeId) setQuery((current) => ({ ...current, [activeId]: value }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const activeIndex = data.components.length - 1;
|
||||
const activeId = data.components[activeIndex]?.id ?? data.components[0]?.id;
|
||||
const val = (query[activeId ?? ""] ?? "").trim();
|
||||
if (val) {
|
||||
createPendingIngredient(activeIndex, val);
|
||||
setQuery((current) => ({ ...current, [activeId]: "" }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{Boolean((query[data.components[data.components.length - 1]?.id ?? data.components[0]?.id ?? ""] ?? "").trim()) && (
|
||||
<div class="recipe-item-results">
|
||||
{[
|
||||
...[...ingredients, ...pendingIngredients].map((item) => ({ ...item, kind: "ingredient" as const })),
|
||||
...recipes.filter((item) => item.id !== recipeId).map((item) => ({ ...item, kind: "recipe" as const })),
|
||||
].filter((item) => {
|
||||
const activeId = data.components[data.components.length - 1]?.id ?? data.components[0]?.id ?? "";
|
||||
const q = (query[activeId] ?? "").trim().toLowerCase();
|
||||
return [item.name, ...(item.aliases ?? [])].some((name) => name.toLowerCase().includes(q));
|
||||
}).slice(0, 8).map((item) => (
|
||||
<button type="button" onClick={() => {
|
||||
const activeIndex = data.components.length - 1;
|
||||
const activeId = data.components[activeIndex]?.id ?? data.components[0]?.id ?? "";
|
||||
addLine(activeIndex, `${item.kind}:${item.id}`);
|
||||
setQuery((current) => ({ ...current, [activeId]: "" }));
|
||||
}}><strong>{item.name}</strong><small>{item.kind === "recipe" ? "Recipe" : "Ingredient"}</small></button>
|
||||
))}
|
||||
{(() => {
|
||||
const activeId = data.components[data.components.length - 1]?.id ?? data.components[0]?.id ?? "";
|
||||
const q = query[activeId] ?? "";
|
||||
if (!ingredients.some((item) => normal(item.name) === normal(q)) && !pendingIngredients.some((item) => normal(item.name) === normal(q))) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="create-ingredient-result"
|
||||
onClick={() => createPendingIngredient(componentIndex, query[component.id])}
|
||||
onClick={() => {
|
||||
const activeIndex = data.components.length - 1;
|
||||
createPendingIngredient(activeIndex, q);
|
||||
setQuery((current) => ({ ...current, [activeId]: "" }));
|
||||
}}
|
||||
>
|
||||
<strong>+ Create “{query[component.id].trim()}”</strong>
|
||||
<strong>+ Create “{q.trim()}”</strong>
|
||||
<small>New ingredient</small>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
<input
|
||||
class="add-line-notes"
|
||||
value={draftNotes[component.id] ?? ""}
|
||||
placeholder="Add notes"
|
||||
aria-label={`Notes for the new item in ${component.name}`}
|
||||
onInput={(event) => setDraftNotes((current) => ({ ...current, [component.id]: event.currentTarget.value }))}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<div class="structure-add-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: [
|
||||
...current.components,
|
||||
{
|
||||
id: uid("component"),
|
||||
name: "New component",
|
||||
items: [],
|
||||
notes: [],
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
Add Header
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setData((current) => {
|
||||
if (current.components.length === 0) {
|
||||
)}
|
||||
</div>
|
||||
<div class="quick-add-notes">
|
||||
<input placeholder="Add notes" aria-label="Add notes" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="structure-bottom-bar">
|
||||
<div class="editor-keyboard-hint">
|
||||
<span><kbd>Enter</kbd> to complete</span>
|
||||
<span><kbd>Tab</kbd> to add a note</span>
|
||||
</div>
|
||||
<div class="structure-add-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
components: [
|
||||
...current.components,
|
||||
{
|
||||
id: uid("component"),
|
||||
name: "",
|
||||
items: [],
|
||||
notes: [],
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>
|
||||
Add Header
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
setData((current) => {
|
||||
if (current.components.length === 0) {
|
||||
return {
|
||||
...current,
|
||||
components: [{ id: uid("component"), name: "", items: [], notes: [""] }],
|
||||
};
|
||||
}
|
||||
const lastIndex = current.components.length - 1;
|
||||
return {
|
||||
...current,
|
||||
components: [{ id: uid("component"), name: "New component", items: [], notes: [""] }],
|
||||
components: current.components.map((component, index) =>
|
||||
index === lastIndex ? { ...component, notes: [...(component.notes ?? []), ""] } : component
|
||||
),
|
||||
};
|
||||
}
|
||||
const lastIndex = current.components.length - 1;
|
||||
return {
|
||||
...current,
|
||||
components: current.components.map((component, index) =>
|
||||
index === lastIndex ? { ...component, notes: [...(component.notes ?? []), ""] } : component
|
||||
),
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
Add Note
|
||||
</button>
|
||||
<button class="bulk-add-trigger" type="button" onClick={() => { setBulkError(""); setBulkOpen(true); }}>
|
||||
Add Ingredients
|
||||
</button>
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>
|
||||
Add Note
|
||||
</button>
|
||||
<button class="bulk-add-trigger" type="button" onClick={() => { setBulkError(""); setBulkOpen(true); }}>
|
||||
Add Ingredients
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{bulkOpen && (
|
||||
<div class="bulk-ingredient-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setBulkOpen(false); }}>
|
||||
@@ -833,12 +929,11 @@ export default function RecipeStructureEditor({
|
||||
autoFocus
|
||||
value={prepText}
|
||||
onInput={(event) => { setPrepText(event.currentTarget.value); setPrepError(""); }}
|
||||
placeholder={'Dry Mix:\nHeat oven to 350°F\nCombine dry ingredients in a medium bowl\n\nGarnish:\nAdd chopped chocolate chips as garnish\n(note about plating)'}
|
||||
placeholder={'Cut & Portion Pork Belly:\n1. Dice pork belly into 1" cubes\n2. Transfer into container\n\nTo Marinate:\n3. Mix gochujang, mustard, and oil\n4. Coat pork thoroughly and refrigerate'}
|
||||
/>
|
||||
{prepError && <p class="bulk-dialog-error">{prepError}</p>}
|
||||
<div class="bulk-entry-help">
|
||||
<span>New line <small>represents a new prep step</small></span>
|
||||
<span>Add headers <small>using a colon : eg To Garnish:</small></span>
|
||||
<span>Add headers <small>using a colon : eg To Sear:</small></span>
|
||||
<span>Add notes to prep method <small>by putting them in (notes)</small></span>
|
||||
</div>
|
||||
<footer>
|
||||
@@ -849,60 +944,137 @@ export default function RecipeStructureEditor({
|
||||
</div>
|
||||
)}
|
||||
{showMethod && (
|
||||
<section class="method-editor">
|
||||
<div id="recipe-cover-slot"></div>
|
||||
<h2>Prep Method <small>{data.steps.length}</small></h2>
|
||||
<ol>
|
||||
{data.steps.map((step, index) => {
|
||||
const stepKind = step.instruction.trim().endsWith(":") ? " prep-heading" : /^\(.+\)$/.test(step.instruction.trim()) ? " prep-note" : "";
|
||||
return (
|
||||
<li class={`${dragging?.kind === "step" && dragging.index === index ? "dragging" : ""}${stepKind}`} onDragOver={(event) => event.preventDefault()} onDrop={() => {
|
||||
if (dragging?.kind === "step") setData((current) => ({ ...current, steps: move(current.steps, dragging.index, index) }));
|
||||
setDragging(undefined);
|
||||
}}>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={step.instruction}
|
||||
onInput={(event) =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps: current.steps.map((value, position) =>
|
||||
position === index
|
||||
? { ...value, instruction: event.currentTarget.value }
|
||||
: value,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span class="row-actions">
|
||||
<button
|
||||
class="remove-row"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps: current.steps.filter(
|
||||
(_, position) => position !== index,
|
||||
),
|
||||
}))
|
||||
}
|
||||
disabled={data.steps.length === 1}
|
||||
>
|
||||
⊖
|
||||
</button>
|
||||
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={() => setDragging({ kind: "step", index })}>⠿</span>
|
||||
</span>
|
||||
</li>
|
||||
)})}
|
||||
</ol>
|
||||
<button
|
||||
class="secondary-action"
|
||||
onClick={() => { setPrepError(""); setPrepOpen(true); }}
|
||||
>
|
||||
Add Prep Steps
|
||||
</button>
|
||||
<div id="recipe-additional-slot"></div>
|
||||
<div id="recipe-tab-panel-slot"></div>
|
||||
</section>
|
||||
methodTarget ? createPortal(
|
||||
<section class="method-editor">
|
||||
<div class="method-header-row">
|
||||
<h2>Prep Method <small>{data.steps.length}</small></h2>
|
||||
</div>
|
||||
<ol class="method-steps-list">
|
||||
{data.steps.map((step, index) => {
|
||||
const stepKind = step.instruction.trim().endsWith(":") ? " prep-heading" : /^\(.+\)$/.test(step.instruction.trim()) ? " prep-note" : "";
|
||||
return (
|
||||
<li class={`method-step-card${dragging?.kind === "step" && dragging.index === index ? " dragging" : ""}${stepKind}`} onDragOver={(event) => event.preventDefault()} onDrop={() => {
|
||||
if (dragging?.kind === "step") setData((current) => ({ ...current, steps: move(current.steps, dragging.index, index) }));
|
||||
setDragging(undefined);
|
||||
}}>
|
||||
<span class="step-num">{index + 1}.</span>
|
||||
<div class="step-body">
|
||||
<textarea
|
||||
rows={2}
|
||||
class="step-textarea"
|
||||
placeholder="Add Prep Step"
|
||||
value={step.instruction}
|
||||
onInput={(event) =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps: current.steps.map((value, position) =>
|
||||
position === index
|
||||
? { ...value, instruction: event.currentTarget.value }
|
||||
: value,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="step-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-step-btn"
|
||||
title="Delete step"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps: current.steps.filter(
|
||||
(_, position) => position !== index,
|
||||
),
|
||||
}))
|
||||
}
|
||||
disabled={data.steps.length === 1}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"/></svg>
|
||||
</button>
|
||||
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={() => setDragging({ kind: "step", index })}>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path fill="currentColor" d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
)})}
|
||||
</ol>
|
||||
<div class="method-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps: [
|
||||
...current.steps,
|
||||
{ id: uid("step"), instruction: "New Section:", equipment_ids: [] },
|
||||
],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>
|
||||
Add Header
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-link-btn"
|
||||
onClick={() =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps: [
|
||||
...current.steps,
|
||||
{ id: uid("step"), instruction: "(Note)", equipment_ids: [] },
|
||||
],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-prep-btn"
|
||||
onClick={() => { setPrepError(""); setPrepOpen(true); }}
|
||||
>
|
||||
Add Prep Steps
|
||||
</button>
|
||||
</div>
|
||||
</section>,
|
||||
methodTarget
|
||||
) : (
|
||||
<section class="method-editor">
|
||||
<div class="method-header-row">
|
||||
<h2>Prep Method <small>{data.steps.length}</small></h2>
|
||||
</div>
|
||||
<ol class="method-steps-list">
|
||||
{data.steps.map((step, index) => (
|
||||
<li class="method-step-card" key={step.id}>
|
||||
<span class="step-num">{index + 1}.</span>
|
||||
<div class="step-body">
|
||||
<textarea
|
||||
rows={2}
|
||||
class="step-textarea"
|
||||
placeholder="Add Prep Step"
|
||||
value={step.instruction}
|
||||
onInput={(event) =>
|
||||
setData((current) => ({
|
||||
...current,
|
||||
steps: current.steps.map((value, position) =>
|
||||
position === index
|
||||
? { ...value, instruction: event.currentTarget.value }
|
||||
: value,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
+3516
-405
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Meez Unified Design System Tokens
|
||||
* Measured directly from live authenticated Meez session
|
||||
*/
|
||||
:root {
|
||||
/* Surface Colors */
|
||||
--meez-bg-page: #f3f3f3;
|
||||
--meez-bg-card: #fbfbfb;
|
||||
--meez-bg-card-alt: #f1f5fe;
|
||||
--meez-bg-card-hover: #f1f5fe;
|
||||
--meez-bg-white: #ffffff;
|
||||
--meez-bg-active-pill: #dbe4ff;
|
||||
|
||||
/* Text & Ink Colors */
|
||||
--meez-text-primary: #050841;
|
||||
--meez-text-secondary: #202962;
|
||||
--meez-text-muted: #a5a9c1;
|
||||
--meez-text-subtle: #757677;
|
||||
--meez-text-gray: #8283a0;
|
||||
--meez-text-body: rgba(0, 0, 0, 0.87);
|
||||
--meez-text-danger: #f63d48;
|
||||
|
||||
/* Brand, Type Badge & Accent Colors */
|
||||
--meez-blue: #3d5df6;
|
||||
--meez-blue-hover: #304fdf;
|
||||
--meez-blue-active: #1236e1;
|
||||
--meez-blue-tint: #f1f5fe;
|
||||
--meez-blue-badge: #dbe4ff;
|
||||
--meez-type-recipe: #3c4679;
|
||||
--meez-type-ingredient: #3f908a;
|
||||
--meez-type-book: #f3a642;
|
||||
--meez-type-purchase: #3f908a;
|
||||
--meez-green: #3f908a;
|
||||
--meez-green-dark: #20ab85;
|
||||
--meez-orange: #f3a642;
|
||||
--meez-danger: #f63d48;
|
||||
--meez-danger-hover: #e02834;
|
||||
--meez-danger-bg: #fff1f2;
|
||||
--meez-danger-tint: #fff0e8;
|
||||
--meez-danger-dark: #9a3412;
|
||||
|
||||
/* Border Colors */
|
||||
--meez-border-subtle: #f3f3f3;
|
||||
--meez-border-default: #ececec;
|
||||
--meez-border-strong: #dfe3ec;
|
||||
--meez-border-divider: #eeeef3;
|
||||
|
||||
/* Elevation Shadows (Material UI standard elevations) */
|
||||
--meez-shadow-1: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--meez-shadow-elevation-2: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12);
|
||||
--meez-shadow-elevation-4: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12);
|
||||
--meez-shadow-elevation-8: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
|
||||
--meez-shadow-elevation-24: 0 11px 15px -7px rgba(0,0,0,0.2), 0 24px 38px 3px rgba(0,0,0,0.14), 0 9px 46px 8px rgba(0,0,0,0.12);
|
||||
|
||||
/* Border Radii */
|
||||
--meez-radius-xs: 2px;
|
||||
--meez-radius-sm: 4px;
|
||||
--meez-radius-md: 6px;
|
||||
--meez-radius-lg: 8px;
|
||||
--meez-radius-xl: 10px;
|
||||
--meez-radius-pill: 100px;
|
||||
--meez-radius-chip: 999px;
|
||||
--meez-radius-circle: 50%;
|
||||
|
||||
/* Transitions */
|
||||
--meez-transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--meez-transition-normal: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Fonts */
|
||||
--meez-font-sans: "CircularCustCapNum", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
Reference in New Issue
Block a user