feat(filter): implement unified single Meez filter with exact context-aware options and styling
This commit is contained in:
@@ -39,10 +39,58 @@ const normalizedQuery=query.toLocaleLowerCase();
|
||||
const validSearchTypes=["recipe","ingredient","book","purchase"];
|
||||
const selectedSearchTypes=Astro.url.searchParams.getAll("item_type").filter((value)=>validSearchTypes.includes(value));
|
||||
const filteringSearchTypes=selectedSearchTypes.length>0;
|
||||
const attention=Astro.url.searchParams.get("attention")==="1", missingCost=Astro.url.searchParams.get("missing_cost")==="1", noUsda=Astro.url.searchParams.get("no_usda")==="1",unused=Astro.url.searchParams.get("unused")==="1",emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1",placeholderSteps=Astro.url.searchParams.get("placeholder_steps")==="1";
|
||||
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)));
|
||||
|
||||
// Recipe attention filters (Meez spec)
|
||||
const emptyRecipe=Astro.url.searchParams.get("empty_recipe")==="1";
|
||||
const missingYield=Astro.url.searchParams.get("missing_yield")==="1";
|
||||
const placeholderSteps=Astro.url.searchParams.get("placeholder_steps")==="1";
|
||||
const attentionRecipe=Astro.url.searchParams.get("attention")==="1";
|
||||
const recipeFilterActive=emptyRecipe||missingYield||placeholderSteps||attentionRecipe;
|
||||
const activeRecipeFilterCount=[emptyRecipe, missingYield, placeholderSteps].filter(Boolean).length || (attentionRecipe ? 1 : 0);
|
||||
|
||||
// Ingredient attention filters (Meez spec)
|
||||
const unused=Astro.url.searchParams.get("unused")==="1";
|
||||
const missingCost=Astro.url.searchParams.get("missing_cost")==="1";
|
||||
const noPurchase=Astro.url.searchParams.get("no_purchase")==="1" || Astro.url.searchParams.get("no_usda")==="1";
|
||||
const attentionIngredient=Astro.url.searchParams.get("attention")==="1";
|
||||
const ingredientFilterActive=unused||missingCost||noPurchase||attentionIngredient;
|
||||
const activeIngredientFilterCount=[unused, missingCost, noPurchase].filter(Boolean).length || (attentionIngredient ? 1 : 0);
|
||||
|
||||
// Global / context filter count & active state
|
||||
const activeFilterCount = type === "recipe"
|
||||
? activeRecipeFilterCount
|
||||
: type === "ingredient"
|
||||
? activeIngredientFilterCount
|
||||
: selectedSearchTypes.length;
|
||||
const isFilterActive = activeFilterCount > 0;
|
||||
|
||||
const filteredIngredients=ingredients.filter((ingredient)=>{
|
||||
if (!ingredientFilterActive) return true;
|
||||
const conditions = [
|
||||
unused && ingredient.recipe_count === 0,
|
||||
missingCost && ingredient.price_count === 0,
|
||||
noPurchase && (ingredient.price_count === 0 || ingredient.nutrition_count === 0)
|
||||
];
|
||||
if (unused || missingCost || noPurchase) {
|
||||
return conditions.some(Boolean);
|
||||
}
|
||||
return ingredient.recipe_count === 0 || ingredient.price_count === 0 || ingredient.nutrition_count === 0;
|
||||
});
|
||||
|
||||
const filteredRecipes=recipes.filter((recipe)=>{
|
||||
if (!recipeFilterActive) return true;
|
||||
const isMissingYield = !recipe.yield_quantity || Number(recipe.yield_quantity) <= 0 || !recipe.yield_unit_id;
|
||||
const conditions = [
|
||||
emptyRecipe && recipe.item_count === 0,
|
||||
missingYield && isMissingYield,
|
||||
placeholderSteps && recipe.placeholder_count > 0
|
||||
];
|
||||
if (emptyRecipe || missingYield || placeholderSteps) {
|
||||
return conditions.some(Boolean);
|
||||
}
|
||||
return recipe.item_count === 0 || isMissingYield || recipe.placeholder_count > 0;
|
||||
});
|
||||
|
||||
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 0 15.3211 0C14.8996 0 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.0502ZM5.35789 14.4799C5.35789 13.2759 4.54521 12.2525 3.43147 11.9214L3.43147 0.752512C3.43147 0.331105 3.10042 0 2.67895 0C2.25747 0 1.92642 0.331105 1.92642 0.752512L1.92642 11.9214C0.812684 12.2525 0 13.2759 0 14.4799C0 15.714 0.812684 16.7374 1.92642 17.0685L1.92642 19.7459C1.92642 20.1673 2.25747 20.4984 2.67895 20.4984C3.10042 20.4984 3.43147 20.1673 3.43147 19.7459L3.43147 17.0685C4.54521 16.7374 5.35789 15.714 5.35789 14.4799ZM3.85289 14.4799C3.85289 15.112 3.34116 15.6538 2.67895 15.6538C2.01674 15.6538 1.50505 15.112 1.50505 14.4799C1.50505 13.8478 2.01674 13.306 2.67895 13.306C3.34116 13.306 3.85289 13.8478 3.85289 14.4799Z";
|
||||
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"|"inventory";href?:string}>=[
|
||||
{type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"},
|
||||
@@ -89,9 +137,53 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in
|
||||
</nav>}
|
||||
<nav class="workspace-pills" aria-label="Workspaces">
|
||||
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={tab.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>}
|
||||
{(!type || query)&&<details class="filter-menu" open={filteringSearchTypes}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary><form method="get"><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 filter</button>{filteringSearchTypes&&<a href={type?`/app/?type=${type}&q=${encodeURIComponent(query)}`:`/app/?q=${encodeURIComponent(query)}`}>Clear</a>}</div></form></details>}
|
||||
<details class="filter-menu" open={isFilterActive}>
|
||||
<summary>
|
||||
<svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true">
|
||||
<path fill="currentColor" d={FILTER_ICON_PATH}/>
|
||||
</svg>
|
||||
Filter{isFilterActive ? ` · ${activeFilterCount}` : ""}
|
||||
</summary>
|
||||
<form method="get" action="/app/">
|
||||
{type && <input type="hidden" name="type" value={type}/>}
|
||||
{query && <input type="hidden" name="q" value={query}/>}
|
||||
|
||||
{type === "recipe" && (
|
||||
<fieldset>
|
||||
<legend>Needing attention</legend>
|
||||
<label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipes</label>
|
||||
<label><input type="checkbox" name="missing_yield" value="1" checked={missingYield}/> Missing total yield</label>
|
||||
<label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Undefined ingredients / steps</label>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{type === "ingredient" && (
|
||||
<fieldset>
|
||||
<legend>Needing attention</legend>
|
||||
<label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredients</label>
|
||||
<label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label>
|
||||
<label><input type="checkbox" name="no_purchase" value="1" checked={noPurchase}/> No vendor purchase associated</label>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{(!type || query) && (
|
||||
<fieldset>
|
||||
<legend>Item type</legend>
|
||||
<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>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div class="filter-actions">
|
||||
<button type="submit">Apply filter</button>
|
||||
{isFilterActive && (
|
||||
<a href={type ? (query ? `/app/?type=${type}&q=${encodeURIComponent(query)}` : `/app/?type=${type}`) : (query ? `/app/?q=${encodeURIComponent(query)}` : "/app/")}>Clear</a>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</nav>
|
||||
</div>
|
||||
{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>:<>
|
||||
|
||||
@@ -80,14 +80,87 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .new-menu-icon svg { display:block; width:24px; height:24px; }
|
||||
.application-body .workspace-new-menu strong,.application-body .detail-new-menu strong { color:#050841; font-size:16px; font-weight:500; }
|
||||
.application-body .workspace-new-menu > nav a:hover,.application-body .detail-new-menu > div a:hover { background:#f1f5fe; transition:background-color .15s cubic-bezier(0.4,0,0.2,1); }
|
||||
.application-body .search-type-filter form,.application-body .filter-menu form { width:300px; padding:14px 0; color:#202962; background:#fff; border:0; border-radius:4px; box-shadow:0 2px 1px -1px rgba(0,0,0,.2),0 1px 1px 0 rgba(0,0,0,.14),0 1px 3px 0 rgba(0,0,0,.12); }
|
||||
.application-body .search-type-filter label,.application-body .filter-menu label { min-height:42px; padding:8px 20px; font-size:14px; }
|
||||
.application-body .search-type-filter label:hover,.application-body .filter-menu label:hover { background:#f1f5fe; transition:background-color .15s cubic-bezier(0.4,0,0.2,1); }
|
||||
.application-body .filter-menu fieldset { margin:6px 14px; padding:6px 0; border:0; border-top:1px solid #eeeef3; }
|
||||
.application-body .filter-menu legend { padding:12px 6px 4px; color:#a5a9c1; font-size:11px; font-weight:500; letter-spacing:.02em; text-transform:none; }
|
||||
.application-body .search-type-filter form > div,.application-body .filter-menu form > div { justify-content:flex-end; margin:8px 14px 0; padding-top:12px; border-top:1px solid #eeeef3; }
|
||||
.application-body .search-type-filter button,.application-body .filter-menu button { padding:8px 13px; color:#fff; background:#3d5df6; border:0; border-radius:4px; font-size:13px; font-weight:600; }
|
||||
.application-body .search-type-filter form a,.application-body .filter-menu form a { color:#647df8; font-size:13px; text-decoration:none; }
|
||||
.application-body .filter-menu form {
|
||||
width: 280px;
|
||||
padding: 12px 0 8px;
|
||||
color: #050841;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 10px 30px rgba(5, 8, 65, 0.12);
|
||||
}
|
||||
|
||||
.application-body .filter-menu label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 6px 18px;
|
||||
color: #050841;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.application-body .filter-menu label:hover {
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.application-body .filter-menu fieldset {
|
||||
margin: 0;
|
||||
padding: 4px 0 6px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.application-body .filter-menu legend {
|
||||
padding: 6px 18px 4px;
|
||||
color: #8b93a7;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.application-body .filter-menu .filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 18px 4px;
|
||||
border-top: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.application-body .filter-menu .filter-actions button {
|
||||
min-height: 32px;
|
||||
padding: 0 16px;
|
||||
color: #fff;
|
||||
background: #3d5df6;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.application-body .filter-menu .filter-actions button:hover {
|
||||
background: #2b49db;
|
||||
}
|
||||
|
||||
.application-body .filter-menu .filter-actions a {
|
||||
color: #8b93a7;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.application-body .filter-menu .filter-actions a:hover {
|
||||
color: #050841;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.application-body .entity-row-actions > div {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user