feat(ux/nd): collapse Find tab into level picker + two filter sections with active indicators

This commit is contained in:
pyr0ball 2026-04-08 22:51:43 -07:00
parent 8c1443d156
commit 4bb93b78d1

View file

@ -66,170 +66,168 @@
</label> </label>
</div> </div>
<!-- Dietary Constraints Tags --> <!-- Dietary Preferences (collapsible) -->
<div class="form-group">
<label class="form-label">Dietary Constraints</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.constraints"
:key="tag"
class="tag-chip status-badge status-info"
>
{{ tag }}
<button class="chip-remove" @click="removeConstraint(tag)" :aria-label="'Remove constraint: ' + tag">×</button>
</span>
</div>
<input
class="form-input"
v-model="constraintInput"
placeholder="e.g. vegetarian, vegan, gluten-free"
aria-describedby="constraint-hint"
@keydown="onConstraintKey"
@blur="commitConstraintInput"
/>
<span id="constraint-hint" class="form-hint">Press Enter or comma to add each item.</span>
</div>
<!-- Allergies Tags -->
<div class="form-group">
<label class="form-label">Allergies (hard exclusions)</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.allergies"
:key="tag"
class="tag-chip status-badge status-error"
>
{{ tag }}
<button class="chip-remove" @click="removeAllergy(tag)" :aria-label="'Remove allergy: ' + tag">×</button>
</span>
</div>
<input
class="form-input"
v-model="allergyInput"
placeholder="e.g. peanuts, shellfish, dairy"
aria-describedby="allergy-hint"
@keydown="onAllergyKey"
@blur="commitAllergyInput"
/>
<span id="allergy-hint" class="form-hint">Press Enter or comma to add. Allergies are hard exclusions no recipes containing these will appear.</span>
</div>
<!-- Hard Day Mode -->
<div class="form-group">
<label class="flex-start gap-sm hard-day-toggle">
<input type="checkbox" v-model="recipesStore.hardDayMode" />
<span class="form-label" style="margin-bottom: 0;">Hard Day Mode</span>
</label>
<p v-if="recipesStore.hardDayMode" class="text-sm text-secondary mt-xs">
Only suggests quick, simple recipes based on your saved equipment.
</p>
</div>
<!-- Shopping Mode -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
<input type="checkbox" v-model="recipesStore.shoppingMode" />
<span class="form-label" style="margin-bottom: 0;">Open to buying missing ingredients</span>
</label>
<p v-if="recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
All recipes shown regardless of missing ingredients. Affiliate links appear for anything you'd need to buy.
</p>
</div>
<!-- Max Missing hidden in shopping mode (it's lifted automatically) -->
<div v-if="!recipesStore.shoppingMode" class="form-group">
<label class="form-label">Max Missing Ingredients (optional)</label>
<input
type="number"
class="form-input"
min="0"
max="5"
placeholder="Leave blank for no limit"
:value="recipesStore.maxMissing ?? ''"
@input="onMaxMissingInput"
/>
</div>
<!-- Nutrition Filters -->
<details class="collapsible form-group"> <details class="collapsible form-group">
<summary class="form-label collapsible-summary nutrition-summary"> <summary class="collapsible-summary filter-summary">
Nutrition Filters <span class="text-muted text-xs">(per recipe, optional)</span> Dietary preferences
<span v-if="dietaryActive" class="filter-active-dot" aria-label="filters active"></span>
</summary> </summary>
<div class="nutrition-filters-grid mt-xs">
<div class="collapsible-body">
<!-- Dietary Constraints Tags -->
<div class="form-group"> <div class="form-group">
<label class="form-label">Max Calories</label> <label class="form-label">Dietary Constraints</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.constraints"
:key="tag"
class="tag-chip status-badge status-info"
>
{{ tag }}
<button class="chip-remove" @click="removeConstraint(tag)" :aria-label="'Remove constraint: ' + tag">×</button>
</span>
</div>
<input <input
type="number"
class="form-input" class="form-input"
min="0" v-model="constraintInput"
placeholder="e.g. 600" placeholder="e.g. vegetarian, vegan, gluten-free"
:value="recipesStore.nutritionFilters.max_calories ?? ''" aria-describedby="constraint-hint"
@input="onNutritionInput('max_calories', $event)" @keydown="onConstraintKey"
@blur="commitConstraintInput"
/> />
<span id="constraint-hint" class="form-hint">Press Enter or comma to add each item.</span>
</div> </div>
<!-- Allergies Tags -->
<div class="form-group"> <div class="form-group">
<label class="form-label">Max Sugar (g)</label> <label class="form-label">Allergies (hard exclusions)</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.allergies"
:key="tag"
class="tag-chip status-badge status-error"
>
{{ tag }}
<button class="chip-remove" @click="removeAllergy(tag)" :aria-label="'Remove allergy: ' + tag">×</button>
</span>
</div>
<input <input
type="number"
class="form-input" class="form-input"
min="0" v-model="allergyInput"
placeholder="e.g. 10" placeholder="e.g. peanuts, shellfish, dairy"
:value="recipesStore.nutritionFilters.max_sugar_g ?? ''" aria-describedby="allergy-hint"
@input="onNutritionInput('max_sugar_g', $event)" @keydown="onAllergyKey"
@blur="commitAllergyInput"
/> />
<span id="allergy-hint" class="form-hint">Press Enter or comma to add. Allergies are hard exclusions no recipes containing these will appear.</span>
</div> </div>
<!-- Hard Day Mode -->
<div class="form-group"> <div class="form-group">
<label class="form-label">Max Carbs (g)</label> <label class="flex-start gap-sm hard-day-toggle">
<input type="checkbox" v-model="recipesStore.hardDayMode" />
<span class="form-label" style="margin-bottom: 0;">Hard Day Mode</span>
</label>
<p v-if="recipesStore.hardDayMode" class="text-sm text-secondary mt-xs">
Only suggests quick, simple recipes based on your saved equipment.
</p>
</div>
<!-- Shopping Mode (temporary home moves to Shopping tab in #71) -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
<input type="checkbox" v-model="recipesStore.shoppingMode" />
<span class="form-label" style="margin-bottom: 0;">Open to buying missing ingredients</span>
</label>
<p v-if="recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
All recipes shown regardless of missing ingredients. Affiliate links appear for anything you'd need to buy.
</p>
</div>
<!-- Max Missing hidden in shopping mode -->
<div v-if="!recipesStore.shoppingMode" class="form-group">
<label class="form-label">Max Missing Ingredients (optional)</label>
<input <input
type="number" type="number"
class="form-input" class="form-input"
min="0" min="0"
placeholder="e.g. 50" max="5"
:value="recipesStore.nutritionFilters.max_carbs_g ?? ''" placeholder="Leave blank for no limit"
@input="onNutritionInput('max_carbs_g', $event)" :value="recipesStore.maxMissing ?? ''"
/> @input="onMaxMissingInput"
</div>
<div class="form-group">
<label class="form-label">Max Sodium (mg)</label>
<input
type="number"
class="form-input"
min="0"
placeholder="e.g. 800"
:value="recipesStore.nutritionFilters.max_sodium_mg ?? ''"
@input="onNutritionInput('max_sodium_mg', $event)"
/> />
</div> </div>
</div> </div>
<p class="text-xs text-muted mt-xs">
Recipes without nutrition data always appear. Filters apply to food.com and estimated values.
</p>
</details> </details>
<!-- Cuisine Style (Level 3+ only) --> <!-- Advanced Filters (collapsible) -->
<div v-if="recipesStore.level >= 3" class="form-group"> <details class="collapsible form-group">
<label class="form-label">Cuisine Style <span class="text-muted text-xs">(Level 3+)</span></label> <summary class="collapsible-summary filter-summary">
<div class="flex flex-wrap gap-xs"> Advanced filters
<button <span v-if="advancedActive" class="filter-active-dot" aria-label="filters active"></span>
v-for="style in cuisineStyles" </summary>
:key="style.id"
:class="['btn', 'btn-secondary', 'btn-sm', { active: recipesStore.styleId === style.id }]"
@click="recipesStore.styleId = recipesStore.styleId === style.id ? null : style.id"
>{{ style.label }}</button>
</div>
</div>
<!-- Category Filter (Level 12 only) --> <div class="collapsible-body">
<div v-if="recipesStore.level <= 2" class="form-group"> <!-- Nutrition Filters -->
<label class="form-label">Category <span class="text-muted text-xs">(optional)</span></label> <div class="form-group">
<input <label class="form-label">Nutrition limits <span class="text-muted text-xs">(per recipe, optional)</span></label>
class="form-input" <div class="nutrition-filters-grid mt-xs">
v-model="categoryInput" <div class="form-group">
placeholder="e.g. Breakfast, Asian, Chicken, &lt; 30 Mins" <label class="form-label">Max Calories</label>
@blur="recipesStore.category = categoryInput.trim() || null" <input type="number" class="form-input" min="0" placeholder="e.g. 600"
@keydown.enter="recipesStore.category = categoryInput.trim() || null" :value="recipesStore.nutritionFilters.max_calories ?? ''"
/> @input="onNutritionInput('max_calories', $event)" />
</div> </div>
<div class="form-group">
<label class="form-label">Max Sugar (g)</label>
<input type="number" class="form-input" min="0" placeholder="e.g. 10"
:value="recipesStore.nutritionFilters.max_sugar_g ?? ''"
@input="onNutritionInput('max_sugar_g', $event)" />
</div>
<div class="form-group">
<label class="form-label">Max Carbs (g)</label>
<input type="number" class="form-input" min="0" placeholder="e.g. 50"
:value="recipesStore.nutritionFilters.max_carbs_g ?? ''"
@input="onNutritionInput('max_carbs_g', $event)" />
</div>
<div class="form-group">
<label class="form-label">Max Sodium (mg)</label>
<input type="number" class="form-input" min="0" placeholder="e.g. 800"
:value="recipesStore.nutritionFilters.max_sodium_mg ?? ''"
@input="onNutritionInput('max_sodium_mg', $event)" />
</div>
</div>
<p class="text-xs text-muted mt-xs">
Recipes without nutrition data always appear.
</p>
</div>
<!-- Cuisine Style (Level 3+ only) -->
<div v-if="recipesStore.level >= 3" class="form-group">
<label class="form-label">Cuisine Style</label>
<div class="flex flex-wrap gap-xs">
<button
v-for="style in cuisineStyles"
:key="style.id"
:class="['btn', 'btn-secondary', 'btn-sm', { active: recipesStore.styleId === style.id }]"
@click="recipesStore.styleId = recipesStore.styleId === style.id ? null : style.id"
>{{ style.label }}</button>
</div>
</div>
<!-- Category Filter (Level 12 only) -->
<div v-if="recipesStore.level <= 2" class="form-group">
<label class="form-label">Category <span class="text-muted text-xs">(optional)</span></label>
<input
class="form-input"
v-model="categoryInput"
placeholder="e.g. Breakfast, Asian, Chicken, &lt; 30 Mins"
@blur="recipesStore.category = categoryInput.trim() || null"
@keydown.enter="recipesStore.category = categoryInput.trim() || null"
/>
</div>
</div>
</details>
<!-- Suggest Button --> <!-- Suggest Button -->
<div class="suggest-row"> <div class="suggest-row">
@ -657,6 +655,20 @@ const levels = [
{ value: 4, label: 'Surprise Me 🎲', description: 'Fully AI-generated — open-ended and occasionally unexpected. Requires paid tier.' }, { value: 4, label: 'Surprise Me 🎲', description: 'Fully AI-generated — open-ended and occasionally unexpected. Requires paid tier.' },
] ]
const dietaryActive = computed(() =>
recipesStore.constraints.length > 0 ||
recipesStore.allergies.length > 0 ||
recipesStore.hardDayMode ||
recipesStore.shoppingMode
)
const advancedActive = computed(() =>
Object.values(recipesStore.nutritionFilters).some((v) => v !== null) ||
recipesStore.maxMissing !== null ||
!!recipesStore.category ||
!!recipesStore.styleId
)
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level)) const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level))
const cuisineStyles = [ const cuisineStyles = [
@ -1052,6 +1064,30 @@ details[open] .collapsible-summary::before {
content: '▼ '; content: '▼ ';
} }
.filter-summary {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-weight: 500;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
}
.filter-active-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
}
.collapsible-body {
padding-top: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.swap-row { .swap-row {
padding: var(--spacing-xs) 0; padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);