feat(ux/nd): collapse Find tab into level picker + two filter sections with active indicators
This commit is contained in:
parent
8c1443d156
commit
4bb93b78d1
1 changed files with 177 additions and 141 deletions
|
|
@ -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 1–2 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, < 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 1–2 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, < 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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue