From 0dc3264a22a02b9c53266e83f2afa723b0938722 Mon Sep 17 00:00:00 2001 From: araemer Date: Sat, 21 Feb 2026 07:44:26 +0100 Subject: [PATCH 1/4] Renaming of rest resources --- frontend/src/api/dtos/RecipeDto.ts | 9 +- frontend/src/api/dtos/TagDto.ts | 8 + frontend/src/api/endpoints/TagRestResource.ts | 27 +++ .../src/components/recipes/RecipeEditPage.tsx | 2 +- .../src/components/recipes/RecipeEditor.tsx | 62 +++---- .../src/components/recipes/RecipeListPage.tsx | 2 +- frontend/src/components/tags/TagChip.tsx | 26 +++ .../src/components/tags/TagListEditor.tsx | 161 ++++++++++++++++++ frontend/src/mappers/RecipeMapper.ts | 42 +++-- frontend/src/mappers/TagMapper.ts | 22 +++ frontend/src/models/RecipeModel.ts | 37 ++-- frontend/src/models/TagModel.ts | 9 + 12 files changed, 328 insertions(+), 79 deletions(-) create mode 100644 frontend/src/api/dtos/TagDto.ts create mode 100644 frontend/src/api/endpoints/TagRestResource.ts create mode 100644 frontend/src/components/tags/TagChip.tsx create mode 100644 frontend/src/components/tags/TagListEditor.tsx create mode 100644 frontend/src/mappers/TagMapper.ts create mode 100644 frontend/src/models/TagModel.ts diff --git a/frontend/src/api/dtos/RecipeDto.ts b/frontend/src/api/dtos/RecipeDto.ts index bc5bedc..dbb9085 100644 --- a/frontend/src/api/dtos/RecipeDto.ts +++ b/frontend/src/api/dtos/RecipeDto.ts @@ -1,7 +1,8 @@ +import {AbstractDto} from "./AbstractDto.ts"; +import {RecipeIngredientGroupDto} from "./RecipeIngredientGroupDto.js"; +import {RecipeInstructionStepDto} from "./RecipeInstructionStepDto.js"; +import type {TagDto} from "./TagDto.ts"; -import { AbstractDto } from "./AbstractDto.ts"; -import { RecipeIngredientGroupDto } from "./RecipeIngredientGroupDto.js"; -import { RecipeInstructionStepDto } from "./RecipeInstructionStepDto.js"; /** * DTO describing a recipe */ @@ -12,4 +13,6 @@ export class RecipeDto extends AbstractDto { amountDescription?: string; instructions!: RecipeInstructionStepDto[]; ingredientGroups!: RecipeIngredientGroupDto[]; + /** Tags associated with this recipe */ + tagList?: TagDto[]; } \ No newline at end of file diff --git a/frontend/src/api/dtos/TagDto.ts b/frontend/src/api/dtos/TagDto.ts new file mode 100644 index 0000000..b20ba40 --- /dev/null +++ b/frontend/src/api/dtos/TagDto.ts @@ -0,0 +1,8 @@ +import {AbstractDto} from "./AbstractDto.ts"; + +/** + * DTO describing a tag + */ +export class TagDto extends AbstractDto { + description!: string; +} \ No newline at end of file diff --git a/frontend/src/api/endpoints/TagRestResource.ts b/frontend/src/api/endpoints/TagRestResource.ts new file mode 100644 index 0000000..6660368 --- /dev/null +++ b/frontend/src/api/endpoints/TagRestResource.ts @@ -0,0 +1,27 @@ +import {apiClient} from "../apiClient" +import type {TagDto} from "../dtos/TagDto" + +const TAG_ENDPOINT = "tags" + +/** + * Fetches all existing tags from the backend. + */ +export async function fetchAllTags(): Promise { + return apiClient.get(TAG_ENDPOINT) +} + +/** + * Creates a new tag or updates an existing one. + * @param tag The tag to create or update + */ +export async function createOrUpdateTag(tag: TagDto): Promise { + return apiClient.post(`${TAG_ENDPOINT}/create-or-update`, tag) +} + +/** + * Deletes a tag by its ID. + * @param id The ID of the tag to delete + */ +export async function deleteTag(id: string): Promise { + return apiClient.delete(`${TAG_ENDPOINT}/${id}`) +} \ No newline at end of file diff --git a/frontend/src/components/recipes/RecipeEditPage.tsx b/frontend/src/components/recipes/RecipeEditPage.tsx index 6a9a5d8..f6fa145 100644 --- a/frontend/src/components/recipes/RecipeEditPage.tsx +++ b/frontend/src/components/recipes/RecipeEditPage.tsx @@ -2,7 +2,7 @@ import {useNavigate, useParams} from "react-router-dom" import {useEffect, useState} from "react" import type {RecipeModel} from "../../models/RecipeModel" import {RecipeEditor} from "./RecipeEditor" -import {createOrUpdateRecipe, fetchRecipe} from "../../api/points/RecipePoint" +import {createOrUpdateRecipe, fetchRecipe} from "../../api/endpoints/RecipeRestResource.ts" import {getRecipeDetailUrl, getRecipeListUrl} from "../../routes" import {mapRecipeDtoToModel, mapRecipeModelToDto} from "../../mappers/RecipeMapper" import type {RecipeDto} from "../../api/dtos/RecipeDto" diff --git a/frontend/src/components/recipes/RecipeEditor.tsx b/frontend/src/components/recipes/RecipeEditor.tsx index 154bf6c..bcb29ac 100644 --- a/frontend/src/components/recipes/RecipeEditor.tsx +++ b/frontend/src/components/recipes/RecipeEditor.tsx @@ -5,11 +5,13 @@ import {IngredientGroupListEditor} from "./IngredientGroupListEditor" import Button from "../basics/Button" import {InstructionStepListEditor} from "./InstructionStepListEditor" import type {InstructionStepModel} from "../../models/InstructionStepModel" +import type {TagModel} from "../../models/TagModel" +import {TagListEditor} from "../tags/TagListEditor" import {ButtonType} from "../basics/BasicButtonDefinitions" -import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx"; -import ContentBody from "../basics/ContentBody.tsx"; -import PageContainer from "../basics/PageContainer.tsx"; -import PageContentLayout from "../basics/PageContentLayout.tsx"; +import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx" +import ContentBody from "../basics/ContentBody.tsx" +import PageContainer from "../basics/PageContainer.tsx" +import PageContentLayout from "../basics/PageContentLayout.tsx" type RecipeEditorProps = { recipe: RecipeModel @@ -23,46 +25,30 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) { /** Error list */ const [errors, setErrors] = useState<{ title?: boolean; ingredients?: boolean }>({}) - /** - * Update ingredients - * @param ingredientGroupList updated ingredient groups and ingredients - */ const updateIngredientGroupList = (ingredientGroupList: IngredientGroupModel[]) => { setDraft({...draft, ingredientGroupList}) } - /** - * Update instruction steps - * @param instructionStepList updated instructions - */ const updateInstructionList = (instructionStepList: InstructionStepModel[]) => { setDraft({...draft, instructionStepList}) } - /** - * Validate recipe - * @returns Information on the errors the validation encountered - */ + const updateTagList = (tagList: TagModel[]) => { + setDraft({...draft, tagList}) + } + const validate = () => { const newErrors: { title?: boolean; ingredients?: boolean } = {} - // each recipe requires a title if (!draft.title.trim()) { newErrors.title = true } - /* there must be at least one ingredient group - * no group may contain an empty ingredient list - * @todo check whether all ingredients are valid - * @todo enhance visualization of ingredient errors - */ if (!draft.ingredientGroupList || draft.ingredientGroupList.length === 0) { newErrors.ingredients = true } else { const isAnyIngredientListEmpty = draft.ingredientGroupList.some( - ingGrp => { - return !ingGrp.ingredientList || ingGrp.ingredientList.length === 0 - } + ingGrp => !ingGrp.ingredientList || ingGrp.ingredientList.length === 0 ) if (isAnyIngredientListEmpty) { newErrors.ingredients = true @@ -70,22 +56,19 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) { } setErrors(newErrors) - return Object.keys(newErrors).length === 0 } - /** Handles saving and ensures that the draft is only saved if valid */ + const handleSave = (draft: RecipeModel) => { if (validate()) { onSave(draft) } } - // ensure that there is a recipe and show error otherwise + if (!recipe) return
Oops, there's no recipe in RecipeEditor...
- // @todo add handling of images + return ( - /*Container spanning entire screen used to center content horizontally */ - {/* Container defining the maximum width of the content */}

{recipe.id ? "Rezept bearbeiten" : "Neues Rezept"} @@ -99,6 +82,7 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) { value={draft.title} onChange={e => setDraft({...draft, title: e.target.value})} /> + {/* Servings */}

Portionen

@@ -124,7 +108,15 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) { }} />
- {/* Ingredient List - @todo better visualization of errors! */} + + {/* Tags */} +

Tags

+ + + {/* Ingredient List */}
- {/* Instruction List*/} + {/* Instruction List */} - - {/* Save Button */} + + ) +} \ No newline at end of file diff --git a/frontend/src/components/tags/TagListEditor.tsx b/frontend/src/components/tags/TagListEditor.tsx new file mode 100644 index 0000000..ef57aee --- /dev/null +++ b/frontend/src/components/tags/TagListEditor.tsx @@ -0,0 +1,161 @@ +import {useEffect, useRef, useState} from "react" +import type {TagModel} from "../../models/TagModel" +import {TagChip} from "./TagChip" +import {createOrUpdateTag, fetchAllTags} from "../../api/endpoints/TagRestResource.ts" + +type TagListEditorProps = { + /** The tags currently assigned to the recipe */ + tagList: TagModel[] + /** Called whenever the tag list changes */ + onChange: (tagList: TagModel[]) => void +} + +/** + * Tag editor inspired by Jira's label picker. + * + * - Shows existing tags as chips with a × button to remove them. + * - An input field lets the user search through all available tags. + * - Matching tags are shown in a dropdown; the user can pick one or + * confirm the typed text as a brand-new tag with "Create ''" option. + */ +export function TagListEditor({tagList, onChange}: TagListEditorProps) { + const [inputValue, setInputValue] = useState("") + const [availableTags, setAvailableTags] = useState([]) + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const containerRef = useRef(null) + + // Load all available tags once on mount + useEffect(() => { + setIsLoading(true) + fetchAllTags() + .then(dtos => + setAvailableTags(dtos.map(dto => ({id: dto.id, description: dto.description}))) + ) + .catch(console.error) + .finally(() => setIsLoading(false)) + }, []) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsDropdownOpen(false) + setInputValue("") + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + const trimmed = inputValue.trim().toLowerCase() + + /** Tags matching the current input that are not already selected */ + const filteredSuggestions = availableTags.filter( + tag => + tag.description.toLowerCase().includes(trimmed) && + !tagList.some(t => t.id === tag.id || t.description.toLowerCase() === tag.description.toLowerCase()) + ) + + /** Whether the typed text is a genuinely new tag (not in available list) */ + const isNewTag = + trimmed.length > 0 && + !availableTags.some(t => t.description.toLowerCase() === trimmed) + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value) + setIsDropdownOpen(true) + } + + const handleInputFocus = () => { + setIsDropdownOpen(true) + } + + const selectTag = (tag: TagModel) => { + onChange([...tagList, tag]) + setInputValue("") + setIsDropdownOpen(false) + } + + const createNewTag = async () => { + if (!trimmed) return + try { + // Persist to backend; backend returns the tag with its new ID + const created = await createOrUpdateTag({id: undefined as unknown as string, description: trimmed}) + const newTag: TagModel = {id: created.id, description: created.description} + // Add to local available list so it shows up in future searches + setAvailableTags(prev => [...prev, newTag]) + selectTag(newTag) + } catch (e) { + console.error("Failed to create tag", e) + } + } + + const removeTag = (tagToRemove: TagModel) => { + onChange(tagList.filter(t => t !== tagToRemove)) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + if (filteredSuggestions.length > 0 && !isNewTag) { + selectTag(filteredSuggestions[0]) + } else if (isNewTag) { + createNewTag() + } + } else if (e.key === "Escape") { + setIsDropdownOpen(false) + setInputValue("") + } + } + + const showDropdown = isDropdownOpen && (filteredSuggestions.length > 0 || isNewTag) + + return ( +
+ {/* Selected tags — only rendered when at least one tag is present */} + {tagList.length > 0 && ( +
+ {tagList.map((tag, index) => ( + + ))} +
+ )} + + {/* Input */} + + + {/* Dropdown */} + {showDropdown && ( +
    + {filteredSuggestions.map(tag => ( +
  • selectTag(tag)} + className="px-3 py-2 cursor-pointer hover:bg-blue-50" + > + {tag.description} +
  • + ))} + {isNewTag && ( +
  • + Create “{inputValue.trim()}” +
  • + )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/mappers/RecipeMapper.ts b/frontend/src/mappers/RecipeMapper.ts index 397a880..e11422d 100644 --- a/frontend/src/mappers/RecipeMapper.ts +++ b/frontend/src/mappers/RecipeMapper.ts @@ -2,6 +2,7 @@ import type {RecipeModel} from "../models/RecipeModel" import type {RecipeDto} from "../api/dtos/RecipeDto" import type {RecipeIngredientGroupDto} from "../api/dtos/RecipeIngredientGroupDto" import type {RecipeInstructionStepDto} from "../api/dtos/RecipeInstructionStepDto" +import {mapTagDtoToModel, mapTagModelToDto} from "./TagMapper" /** * Maps a RecipeDto (as returned by the backend) to the Recipe model @@ -16,36 +17,33 @@ export function mapRecipeDtoToModel(dto: RecipeDto): RecipeModel { amount: dto.amount ?? 1, unit: dto.amountDescription ?? "", }, - // join all instruction step texts into a single string for display + instructionStepList: dto.instructions - .sort((a, b) => a.sortOrder - b.sortOrder) // ensure correct order + .sort((a, b) => a.sortOrder - b.sortOrder) .map(step => ({ - text: step.text, - id: step.id, - /* When mapping a stepDTO, it should already contain a UUID. If - * If, however, for some reason, it does not, add a UUID as sorting - * steps in teh GUI requires a unique identifier for each item. - */ - internalId: step.id !== undefined ? step.id : crypto.randomUUID() - }) - ), + text: step.text, + id: step.id, + internalId: step.id !== undefined ? step.id : crypto.randomUUID() + })), ingredientGroupList: dto.ingredientGroups - .sort((a, b) => a.sortOrder - b.sortOrder) // ensure groups are ordered + .sort((a, b) => a.sortOrder - b.sortOrder) .map(group => ({ id: group.id, title: group.title, ingredientList: group.ingredients - .sort((a, b) => a.sortOrder - b.sortOrder) // ensure ingredients are ordered + .sort((a, b) => a.sortOrder - b.sortOrder) .map(ing => ({ id: ing.id, - name: ing.name ?? "", // @todo ensure that name and amount are indeed present + name: ing.name ?? "", amount: ing.amount, unit: ing.unit, - //subtext: ing.subtext ?? undefined, })), })), - imageUrl: undefined, // not part of DTO yet, placeholder + + tagList: (dto.tagList ?? []).map(mapTagDtoToModel), + + imageUrl: undefined, } } @@ -54,7 +52,6 @@ export function mapRecipeDtoToModel(dto: RecipeDto): RecipeModel { * for sending updates or creations to the backend. */ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto { - // Map instructions const instructionDtos: RecipeInstructionStepDto[] = model.instructionStepList.map( (step, index) => ({ id: step.id, @@ -63,22 +60,22 @@ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto { }) ) - // Map ingredients const ingredientGroupDtos: RecipeIngredientGroupDto[] = model.ingredientGroupList.map((group, groupIndex) => ({ id: group.id, title: group.title, - sortOrder: groupIndex + 1, // sortOrder from list index + sortOrder: groupIndex + 1, ingredients: group.ingredientList.map((ing, ingIndex) => ({ id: ing.id, name: ing.name, amount: ing.amount, unit: ing.unit, - sortOrder: ingIndex + 1, // sortOrder from index - //subtext: ing.subtext ?? null, + sortOrder: ingIndex + 1, })), })) + const tagDtos = model.tagList.map(mapTagModelToDto) + return { id: model.id, title: model.title, @@ -86,5 +83,6 @@ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto { amountDescription: model.servings.unit, instructions: instructionDtos, ingredientGroups: ingredientGroupDtos, + tagList: tagDtos, } -} +} \ No newline at end of file diff --git a/frontend/src/mappers/TagMapper.ts b/frontend/src/mappers/TagMapper.ts new file mode 100644 index 0000000..f497eac --- /dev/null +++ b/frontend/src/mappers/TagMapper.ts @@ -0,0 +1,22 @@ +import type {TagModel} from "../models/TagModel" +import type {TagDto} from "../api/dtos/TagDto" + +/** + * Maps a TagDto (as returned by the backend) to the TagModel used in the frontend. + */ +export function mapTagDtoToModel(dto: TagDto): TagModel { + return { + id: dto.id, + description: dto.description, + } +} + +/** + * Maps a TagModel (as used in the frontend) back to a TagDto for sending to the backend. + */ +export function mapTagModelToDto(model: TagModel): TagDto { + return { + id: model.id!, + description: model.description, + } +} \ No newline at end of file diff --git a/frontend/src/models/RecipeModel.ts b/frontend/src/models/RecipeModel.ts index d212a64..dae6935 100644 --- a/frontend/src/models/RecipeModel.ts +++ b/frontend/src/models/RecipeModel.ts @@ -1,10 +1,12 @@ -import type { IngredientGroupModel } from "./IngredientGroupModel" -import type { InstructionStepModel } from "./InstructionStepModel" -import type { ServingsModel } from "./ServingsModel" +import type {IngredientGroupModel} from "./IngredientGroupModel" +import type {InstructionStepModel} from "./InstructionStepModel" +import type {ServingsModel} from "./ServingsModel" +import type {TagModel} from "./TagModel.ts"; /** * Represents a recipe object in the application. */ + /* * @todo ingredient groups! There may be serveral ingredient lists, each with a title. * e.g. for the dough, for the filling, for the icing,... @@ -13,24 +15,27 @@ import type { ServingsModel } from "./ServingsModel" * - add an IngredientGroupListEditor for handling IngredientGroups */ export interface RecipeModel { - /** Unique identifier for the recipe */ - id?: string + /** Unique identifier for the recipe */ + id?: string - /** Title of the recipe */ - title: string + /** Title of the recipe */ + title: string - /** List of ingredients groups containing the ingredients of the recipe */ - ingredientGroupList: IngredientGroupModel[] + /** List of ingredients groups containing the ingredients of the recipe */ + ingredientGroupList: IngredientGroupModel[] - /** Preparation instructions */ - instructionStepList: InstructionStepModel[] + /** Preparation instructions */ + instructionStepList: InstructionStepModel[] - /** Number of servings, e.g., for 4 person, 12 cupcakes, 2 glasses */ - servings: ServingsModel + /** Number of servings, e.g., for 4 person, 12 cupcakes, 2 glasses */ + servings: ServingsModel - /** Unit for the quantity */ + /** Unit for the quantity */ - /** Optional image URL for the recipe */ - imageUrl?: string + /** Optional image URL for the recipe */ + imageUrl?: string + + /** Tags categorising the recipe, e.g. "dessert", "vegetarian", "christmas" */ + tagList: TagModel[] } diff --git a/frontend/src/models/TagModel.ts b/frontend/src/models/TagModel.ts new file mode 100644 index 0000000..674af1e --- /dev/null +++ b/frontend/src/models/TagModel.ts @@ -0,0 +1,9 @@ +/** + * Represents a tag in the application. + */ +export interface TagModel { + /** Unique identifier for the tag */ + id?: string + /** Human-readable label, e.g. "dessert", "vegetarian", "christmas" */ + description: string +} \ No newline at end of file From 709cb23f3da4e877e516cdc4e87b1a7fa98409de Mon Sep 17 00:00:00 2001 From: araemer Date: Sun, 22 Feb 2026 13:15:48 +0100 Subject: [PATCH 2/4] Add the API Client to all rest resources --- frontend/src/api/apiClient.ts | 2 -- .../src/api/endpoints/AuthRestResource.ts | 14 +++------- .../endpoints/CompactRecipeRestResource.ts | 27 +++++++------------ .../src/api/endpoints/RecipeRestResource.ts | 19 ++++--------- frontend/src/api/endpoints/TagRestResource.ts | 8 +++--- .../src/api/endpoints/UserRestResource.ts | 12 +++++---- 6 files changed, 29 insertions(+), 53 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index a7185f3..fe4653a 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -90,8 +90,6 @@ export const apiClient = { get: (endpoint: string) => apiRequest(endpoint, {method: "GET"}), post: (endpoint: string, body: object) => apiRequest(endpoint, {method: "POST", body: JSON.stringify(body)}), - put: (endpoint: string, body: object) => - apiRequest(endpoint, {method: "PUT", body: JSON.stringify(body)}), delete: (endpoint: string) => apiRequest(endpoint, {method: "DELETE"}), }; diff --git a/frontend/src/api/endpoints/AuthRestResource.ts b/frontend/src/api/endpoints/AuthRestResource.ts index 603eb1c..2e18229 100644 --- a/frontend/src/api/endpoints/AuthRestResource.ts +++ b/frontend/src/api/endpoints/AuthRestResource.ts @@ -1,18 +1,11 @@ import type {LoginRequest} from "../dtos/LoginRequest.ts"; import type {LoginResponse} from "../dtos/LoginResponse.ts"; -import {postJson} from "../utils/requests"; - - -/** - * Util for handling the recipe api - */ -// read base url from .env file -const BASE_URL = import.meta.env.VITE_API_BASE; +import {apiClient} from "../apiClient.ts"; /** * URL for handling recipes */ -const AUTH_URL = `${BASE_URL}/auth` +const AUTH_URL = `/auth` /** @@ -21,6 +14,5 @@ const AUTH_URL = `${BASE_URL}/auth` * @returns LoginResponse */ export async function login(loginRequest: LoginRequest): Promise { - const res = await postJson(`${AUTH_URL}/login`, JSON.stringify(loginRequest), false); - return res.json(); + return apiClient.post(`${AUTH_URL}/login`, loginRequest); } diff --git a/frontend/src/api/endpoints/CompactRecipeRestResource.ts b/frontend/src/api/endpoints/CompactRecipeRestResource.ts index 45cf28b..dd374e1 100644 --- a/frontend/src/api/endpoints/CompactRecipeRestResource.ts +++ b/frontend/src/api/endpoints/CompactRecipeRestResource.ts @@ -1,29 +1,22 @@ -import type { RecipeModel } from "../../models/RecipeModel" -import { get } from "../utils/requests"; +import type {RecipeModel} from "../../models/RecipeModel" +import {apiClient} from "../apiClient.ts"; -/** - * Util for handling the recipe api - */ -// read base url from .env file -const BASE_URL = import.meta.env.VITE_API_BASE; - /** * URL for handling recipes header data */ -const RECIPE_URL = `${BASE_URL}/compact-recipe` +const RECIPE_URL = "/compact-recipe" /** * Load list of all recipes * @param searchString Search string for filtering recipeList * @returns Array of recipe */ -export async function fetchRecipeList(searchString : string): Promise { - let url : string = RECIPE_URL; // add an s to the base URL as we want to load a list - // if there's a search string add it as query parameter - if(searchString && searchString !== ""){ - url +="?search=" + searchString; - } - const res = await get(url); - return res.json(); +export async function fetchRecipeList(searchString: string): Promise { + let url: string = RECIPE_URL; // add an s to the base URL as we want to load a list + // if there's a search string add it as query parameter + if (searchString && searchString !== "") { + url += "?search=" + searchString; + } + return apiClient.get(url); } diff --git a/frontend/src/api/endpoints/RecipeRestResource.ts b/frontend/src/api/endpoints/RecipeRestResource.ts index 35b833e..ea7a575 100644 --- a/frontend/src/api/endpoints/RecipeRestResource.ts +++ b/frontend/src/api/endpoints/RecipeRestResource.ts @@ -1,17 +1,10 @@ -import type { RecipeDto } from "../dtos/RecipeDto"; -import { get, postJson } from "../utils/requests"; - - -/** - * Util for handling the recipe api - */ -// read base url from .env file -const BASE_URL = import.meta.env.VITE_API_BASE; +import type {RecipeDto} from "../dtos/RecipeDto"; +import {apiClient} from "../apiClient.ts"; /** * URL for handling recipes */ -const RECIPE_URL = `${BASE_URL}/recipe` +const RECIPE_URL = "/recipe" /** * Load a single recipe @@ -19,8 +12,7 @@ const RECIPE_URL = `${BASE_URL}/recipe` * @returns A single recipe */ export async function fetchRecipe(id: string): Promise { - const res = await get(`${RECIPE_URL}/${id}`) - return res.json() + return apiClient.get(`${RECIPE_URL}/${id}`); } /** @@ -29,6 +21,5 @@ export async function fetchRecipe(id: string): Promise { * @returns Saved recipe */ export async function createOrUpdateRecipe(recipe: RecipeDto): Promise { - const res = await postJson(RECIPE_URL + "/create-or-update", JSON.stringify(recipe)); - return res.json(); + return apiClient.post(`${RECIPE_URL}/create-or-update`, recipe); } diff --git a/frontend/src/api/endpoints/TagRestResource.ts b/frontend/src/api/endpoints/TagRestResource.ts index 6660368..9ba5319 100644 --- a/frontend/src/api/endpoints/TagRestResource.ts +++ b/frontend/src/api/endpoints/TagRestResource.ts @@ -1,13 +1,13 @@ import {apiClient} from "../apiClient" import type {TagDto} from "../dtos/TagDto" -const TAG_ENDPOINT = "tags" +const TAG_URL = `/tag` /** * Fetches all existing tags from the backend. */ export async function fetchAllTags(): Promise { - return apiClient.get(TAG_ENDPOINT) + return apiClient.get(`${TAG_URL}/all`) } /** @@ -15,7 +15,7 @@ export async function fetchAllTags(): Promise { * @param tag The tag to create or update */ export async function createOrUpdateTag(tag: TagDto): Promise { - return apiClient.post(`${TAG_ENDPOINT}/create-or-update`, tag) + return apiClient.post(`${TAG_URL}/create-or-update`, tag) } /** @@ -23,5 +23,5 @@ export async function createOrUpdateTag(tag: TagDto): Promise { * @param id The ID of the tag to delete */ export async function deleteTag(id: string): Promise { - return apiClient.delete(`${TAG_ENDPOINT}/${id}`) + return apiClient.delete(`${TAG_URL}/${id}`) } \ No newline at end of file diff --git a/frontend/src/api/endpoints/UserRestResource.ts b/frontend/src/api/endpoints/UserRestResource.ts index 02d0695..a305ad8 100644 --- a/frontend/src/api/endpoints/UserRestResource.ts +++ b/frontend/src/api/endpoints/UserRestResource.ts @@ -5,22 +5,24 @@ import type {ChangeUserPasswordRequest} from "../dtos/ChangeUserPasswordRequest. import type {CreateUserResponse} from "../dtos/CreateUserResponse.ts"; import type {UserListResponse} from "../dtos/UserListResponse.ts"; +const USER_URL = "/user" + export async function fetchCurrentUser(): Promise { - return apiClient.get("/user/me"); + return apiClient.get(`${USER_URL}/me`); } export async function fetchAllUsers(): Promise { - return apiClient.get("/user/all"); + return apiClient.get(`${USER_URL}/all`); } export async function createUser(dto: CreateUserRequest): Promise { - return apiClient.post("/user/create", dto); + return apiClient.post(`${USER_URL}/create`, dto); } export async function updateUser(dto: UserDto): Promise { - return apiClient.post("/user/update", dto); + return apiClient.post(`${USER_URL}/update`, dto); } export async function changePassword(dto: ChangeUserPasswordRequest) { - return apiClient.post("/user/change-password", dto); + return apiClient.post(`${USER_URL}/change-password`, dto); } From 79d62b140ea729aa398f0f9f97252e6157cf7ff3 Mon Sep 17 00:00:00 2001 From: araemer Date: Fri, 27 Feb 2026 20:37:07 +0100 Subject: [PATCH 3/4] Adapt to new recipe filter and fix create recipe --- frontend/src/api/dtos/CompactRecipeDto.ts | 11 +++++ .../api/dtos/CompactRecipeFilterRequest.ts | 13 ++++++ .../api/dtos/CompactRecipeFilterResponse.ts | 8 ++++ .../endpoints/CompactRecipeRestResource.ts | 40 ++++++++++------- .../src/components/recipes/RecipeEditPage.tsx | 1 + .../src/components/recipes/RecipeListPage.tsx | 43 ++++++++++--------- frontend/src/mappers/RecipeMapper.ts | 2 +- 7 files changed, 82 insertions(+), 36 deletions(-) create mode 100644 frontend/src/api/dtos/CompactRecipeDto.ts create mode 100644 frontend/src/api/dtos/CompactRecipeFilterRequest.ts create mode 100644 frontend/src/api/dtos/CompactRecipeFilterResponse.ts diff --git a/frontend/src/api/dtos/CompactRecipeDto.ts b/frontend/src/api/dtos/CompactRecipeDto.ts new file mode 100644 index 0000000..db9ad38 --- /dev/null +++ b/frontend/src/api/dtos/CompactRecipeDto.ts @@ -0,0 +1,11 @@ + +import { AbstractDto } from "./AbstractDto.js"; +/** + * DTO describing the essential header data of a recipe + * Used to populate lists + */ + +export class CompactRecipeDto extends AbstractDto { + title!: string; + // @todo add resource and rating here once implemented! +} \ No newline at end of file diff --git a/frontend/src/api/dtos/CompactRecipeFilterRequest.ts b/frontend/src/api/dtos/CompactRecipeFilterRequest.ts new file mode 100644 index 0000000..0d162c7 --- /dev/null +++ b/frontend/src/api/dtos/CompactRecipeFilterRequest.ts @@ -0,0 +1,13 @@ +/** + * Request wrapper for searching recipes based on a filter + */ +export class CompactRecipeFilterRequest { + /** + * Search string applied to the recipe title + */ + searchString?: string; + /** + * List of tags that must be applied to the recipe + */ + tagIdList?: string[]; +} \ No newline at end of file diff --git a/frontend/src/api/dtos/CompactRecipeFilterResponse.ts b/frontend/src/api/dtos/CompactRecipeFilterResponse.ts new file mode 100644 index 0000000..408ba93 --- /dev/null +++ b/frontend/src/api/dtos/CompactRecipeFilterResponse.ts @@ -0,0 +1,8 @@ +import {CompactRecipeDto} from "./CompactRecipeDto.js"; + +/** + * Filter response containing a list of all recipes matching the search + */ +export class CompactRecipeFilterResponse { + compactRecipeList! : CompactRecipeDto[]; +} \ No newline at end of file diff --git a/frontend/src/api/endpoints/CompactRecipeRestResource.ts b/frontend/src/api/endpoints/CompactRecipeRestResource.ts index dd374e1..22a69c8 100644 --- a/frontend/src/api/endpoints/CompactRecipeRestResource.ts +++ b/frontend/src/api/endpoints/CompactRecipeRestResource.ts @@ -1,22 +1,32 @@ -import type {RecipeModel} from "../../models/RecipeModel" import {apiClient} from "../apiClient.ts"; - +import type {CompactRecipeDto} from "../dtos/CompactRecipeDto.ts"; +import type {CompactRecipeFilterRequest} from "../dtos/CompactRecipeFilterRequest.ts"; +import type {CompactRecipeFilterResponse} from "../dtos/CompactRecipeFilterResponse.ts"; /** - * URL for handling recipes header data + * URL for handling recipe header data */ -const RECIPE_URL = "/compact-recipe" +const COMPACT_RECIPE_URL = "/compact-recipe" /** - * Load list of all recipes - * @param searchString Search string for filtering recipeList - * @returns Array of recipe + * Loads recipe header data for all recipes matching the given filter criteria. + * If neither a search string nor tag IDs are provided, all recipes are returned. + * + * @param searchString Optional title search string + * @param tagIdList Optional list of tag IDs the recipe must have all of + * @returns Filtered list of compact recipe DTOs */ -export async function fetchRecipeList(searchString: string): Promise { - let url: string = RECIPE_URL; // add an s to the base URL as we want to load a list - // if there's a search string add it as query parameter - if (searchString && searchString !== "") { - url += "?search=" + searchString; - } - return apiClient.get(url); -} +export async function fetchRecipeList( + searchString?: string, + tagIdList?: string[] +): Promise { + const request: CompactRecipeFilterRequest = { + searchString: searchString && searchString.length > 0 ? searchString : undefined, + tagIdList: tagIdList && tagIdList.length > 0 ? tagIdList : undefined, + }; + const response = await apiClient.post( + `${COMPACT_RECIPE_URL}/list-by-filter`, + request + ); + return response.compactRecipeList; +} \ No newline at end of file diff --git a/frontend/src/components/recipes/RecipeEditPage.tsx b/frontend/src/components/recipes/RecipeEditPage.tsx index f6fa145..dd92ed6 100644 --- a/frontend/src/components/recipes/RecipeEditPage.tsx +++ b/frontend/src/components/recipes/RecipeEditPage.tsx @@ -30,6 +30,7 @@ export default function RecipeEditPage() { title: "", ingredientGroupList: [], instructionStepList: [], + tagList: [], servings: { amount: 1, unit: "" diff --git a/frontend/src/components/recipes/RecipeListPage.tsx b/frontend/src/components/recipes/RecipeListPage.tsx index 786bc2d..dc4359c 100644 --- a/frontend/src/components/recipes/RecipeListPage.tsx +++ b/frontend/src/components/recipes/RecipeListPage.tsx @@ -1,39 +1,41 @@ import {useEffect, useState} from "react" import RecipeListItem from "./RecipeListItem" -import type {RecipeModel} from "../../models/RecipeModel" +import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts" import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.ts" import {useNavigate} from "react-router-dom" import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes" import RecipeListToolbar from "./RecipeListToolbar" -import StickyHeader from "../basics/StickyHeader.tsx"; -import PageContainer from "../basics/PageContainer.tsx"; -import PageContentLayout from "../basics/PageContentLayout.tsx"; +import StickyHeader from "../basics/StickyHeader.tsx" +import PageContainer from "../basics/PageContainer.tsx" +import PageContentLayout from "../basics/PageContentLayout.tsx" + +/** Debounce delay in ms — list only reloads once the user stops typing */ +const SEARCH_DEBOUNCE_MS = 200 /** * Displays a list of recipes in a sidebar layout. * Each recipe link fills the available width. */ export default function RecipeListPage() { - const navigate = useNavigate() - const [recipeList, setRecipeList] = useState(null) + const [recipeList, setRecipeList] = useState(null) const [searchString, setSearchString] = useState("") - // load recipes once on render and whenever search string changes - // @todo add delay. Only reload list if the search string hasn't changed for ~200 ms + const [tagIdList, setTagIdList] = useState([]) + + // Reload list whenever search string or tag filter changes, debounced to + // avoid firing on every keystroke useEffect(() => { - console.log("loading recipe list with searchString", searchString) - const loadRecipeList = async () => { + const timeout = setTimeout(async () => { try { - // Fetch recipe list - const data = await fetchRecipeList(searchString) - // @todo add and use compact recipe mapper + const data = await fetchRecipeList(searchString, tagIdList) setRecipeList(data) } catch (err) { console.error(err) } - } - loadRecipeList() - }, [searchString]) + }, SEARCH_DEBOUNCE_MS) + + return () => clearTimeout(timeout) + }, [searchString, tagIdList]) const handleAdd = () => { navigate(getRecipeAddUrl()) @@ -42,8 +44,9 @@ export default function RecipeListPage() { if (!recipeList) { return
Loading!
} + return ( - /*Container spanning entire screen used to center content horizontally */ + /* Container spanning entire screen used to center content horizontally */ {/* Container defining the maximum width of the content */} @@ -56,7 +59,7 @@ export default function RecipeListPage() { numberOfRecipes={recipeList.length} /> - {/*Content - List of recipe cards */} + {/* Content - List of recipe cards */}
@@ -64,7 +67,7 @@ export default function RecipeListPage() { ))}
@@ -72,4 +75,4 @@ export default function RecipeListPage() { ) -} +} \ No newline at end of file diff --git a/frontend/src/mappers/RecipeMapper.ts b/frontend/src/mappers/RecipeMapper.ts index e11422d..f55dc57 100644 --- a/frontend/src/mappers/RecipeMapper.ts +++ b/frontend/src/mappers/RecipeMapper.ts @@ -77,7 +77,7 @@ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto { const tagDtos = model.tagList.map(mapTagModelToDto) return { - id: model.id, + id: model.id ? model.id : undefined, title: model.title, amount: model.servings.amount, amountDescription: model.servings.unit, From 0804256b6ddbbe546c5c55c2b2cffea387e0e3eb Mon Sep 17 00:00:00 2001 From: araemer Date: Sat, 28 Feb 2026 07:59:33 +0100 Subject: [PATCH 4/4] Add filter component for tags to recipe list toolbar --- .../src/components/basics/FilterDropDown.tsx | 225 ++++++++++++++++++ .../src/components/basics/SearchField.tsx | 31 +-- .../src/components/basics/StickyHeader.tsx | 2 +- .../src/components/recipes/RecipeListPage.tsx | 35 ++- .../components/recipes/RecipeListToolbar.tsx | 64 +++-- 5 files changed, 306 insertions(+), 51 deletions(-) create mode 100644 frontend/src/components/basics/FilterDropDown.tsx diff --git a/frontend/src/components/basics/FilterDropDown.tsx b/frontend/src/components/basics/FilterDropDown.tsx new file mode 100644 index 0000000..ca62e18 --- /dev/null +++ b/frontend/src/components/basics/FilterDropDown.tsx @@ -0,0 +1,225 @@ +import {useEffect, useRef, useState} from "react" +import {Check, ChevronDown, Search, X} from "lucide-react" +import {defaultIconSize} from "./SvgIcon" +import clsx from "clsx" + +/** + * A single selectable option in the dropdown. + * Generic so the component can be reused for tags, categories, users, etc. + */ +export type FilterOption = { + id: string + label: string +} + +/** + * Self-contained model for a FilterDropdown instance. + * Pass this as a single prop to avoid threading multiple filter-related + * props through intermediate components. + */ +export type FilterDropdownModel = { + /** Label shown on the trigger button when nothing is selected, e.g. "Tags" */ + placeholder: string + /** Full list of available options */ + options: FilterOption[] + /** Currently selected option IDs */ + selectedIds: string[] + /** Called whenever the selection changes */ + onSelectionChanged: (selectedIds: string[]) => void +} + +type FilterDropdownProps = { + model: FilterDropdownModel + /** Optional additional Tailwind classes for the root element */ + className?: string +} + +/** + * A generic multi-select filter dropdown styled similarly to Jira's label filter. + * + * - Closed state: shows placeholder, single selected label, or "Multiple (n)" + * - Open state: search field + scrollable checkbox list + deselect-all footer + * - Closes on outside click or Escape + */ +export default function FilterDropdown({model, className = ""}: FilterDropdownProps) { + const {placeholder, options, selectedIds, onSelectionChanged} = model + + const [isOpen, setIsOpen] = useState(false) + const [filterText, setFilterText] = useState("") + const containerRef = useRef(null) + const searchRef = useRef(null) + + // Close on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + closeDropdown() + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + // Focus search input when opening + useEffect(() => { + if (isOpen) { + setTimeout(() => searchRef.current?.focus(), 0) + } + }, [isOpen]) + + const closeDropdown = () => { + setIsOpen(false) + setFilterText("") + } + + const toggleOpen = () => { + if (isOpen) { + closeDropdown() + } else { + setIsOpen(true) + } + } + + const toggleOption = (id: string) => { + if (selectedIds.includes(id)) { + onSelectionChanged(selectedIds.filter(sid => sid !== id)) + } else { + onSelectionChanged([...selectedIds, id]) + } + } + + const deselectAll = () => onSelectionChanged([]) + + // Filter options by search text + const visibleOptions = options.filter(opt => + opt.label.toLowerCase().includes(filterText.toLowerCase()) + ) + + // Derive button label + const buttonLabel = (() => { + if (selectedIds.length === 0) return placeholder + if (selectedIds.length === 1) { + const match = options.find(o => o.id === selectedIds[0]) + return match?.label ?? placeholder + } + return `Multiple (${selectedIds.length})` + })() + + const hasSelection = selectedIds.length > 0 + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown panel */} + {isOpen && ( +
+ {/* Search field inside dropdown */} +
+ setFilterText(e.target.value)} + onKeyDown={e => e.key === "Escape" && closeDropdown()} + className="w-full pl-8 pr-8 py-1.5 text-sm border border-gray-200 rounded focus:outline-none focus:ring-2 focus:ring-blue-300" + /> + + {filterText && ( + + )} +
+ + {/* Options list */} +
    + {visibleOptions.length === 0 ? ( +
  • + No results +
  • + ) : ( + visibleOptions.map(option => { + const isSelected = selectedIds.includes(option.id) + return ( +
  • + +
  • + ) + }) + )} +
+ + {/* Footer: deselect all */} + {hasSelection && ( +
+ +
+ )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/basics/SearchField.tsx b/frontend/src/components/basics/SearchField.tsx index f66e54b..03f4628 100644 --- a/frontend/src/components/basics/SearchField.tsx +++ b/frontend/src/components/basics/SearchField.tsx @@ -19,7 +19,6 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea const [currentSearchString, setCurrentSearchString] = useState("") const changeSearchString = (newSearchString: string) => { - console.log(newSearchString); setCurrentSearchString(newSearchString); onSearchStringChanged(newSearchString) } @@ -27,41 +26,29 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea const iconStyle: string = "text-gray-400 hover:text-gray-500"; return ( -
- {/* Input of searchfield - Defines border and behavior. Requires extra padding at both sides to - accommodate the icons - */} +
changeSearchString(e.target.value)} /> - {/* Right icon: X - Clears search string on click - */} + {/* Right icon: X — clears search string on click */} - {/* Left icon: Looking glass */} + {/* Left icon: looking glass */}
- +
) -} +} \ No newline at end of file diff --git a/frontend/src/components/basics/StickyHeader.tsx b/frontend/src/components/basics/StickyHeader.tsx index 39d6536..f73f63d 100644 --- a/frontend/src/components/basics/StickyHeader.tsx +++ b/frontend/src/components/basics/StickyHeader.tsx @@ -26,7 +26,7 @@ export default function StickyHeader({children, className = ""}: StickyHeaderPro return (
diff --git a/frontend/src/components/recipes/RecipeListPage.tsx b/frontend/src/components/recipes/RecipeListPage.tsx index dc4359c..166969b 100644 --- a/frontend/src/components/recipes/RecipeListPage.tsx +++ b/frontend/src/components/recipes/RecipeListPage.tsx @@ -2,6 +2,8 @@ import {useEffect, useState} from "react" import RecipeListItem from "./RecipeListItem" import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts" import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.ts" +import {fetchAllTags} from "../../api/endpoints/TagRestResource.ts" +import type {FilterDropdownModel, FilterOption} from "../basics/FilterDropDown.tsx" import {useNavigate} from "react-router-dom" import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes" import RecipeListToolbar from "./RecipeListToolbar" @@ -20,14 +22,27 @@ export default function RecipeListPage() { const navigate = useNavigate() const [recipeList, setRecipeList] = useState(null) const [searchString, setSearchString] = useState("") - const [tagIdList, setTagIdList] = useState([]) + const [tagOptions, setTagOptions] = useState([]) + const [selectedTagIds, setSelectedTagIds] = useState([]) - // Reload list whenever search string or tag filter changes, debounced to - // avoid firing on every keystroke + // Load available tags once on mount to populate the filter dropdown + useEffect(() => { + fetchAllTags() + .then(dtos => setTagOptions( + dtos + // ensure that all tags indeed have ids as this is required by the FilterOption + .filter((dto): dto is typeof dto & { id: string } => dto.id !== undefined) + // map to filter option + .map(dto => ({id: dto.id, label: dto.description})) + )) + .catch(console.error) + }, []) + + // Reload recipe list whenever search string or tag selection changes, debounced useEffect(() => { const timeout = setTimeout(async () => { try { - const data = await fetchRecipeList(searchString, tagIdList) + const data = await fetchRecipeList(searchString, selectedTagIds) setRecipeList(data) } catch (err) { console.error(err) @@ -35,10 +50,13 @@ export default function RecipeListPage() { }, SEARCH_DEBOUNCE_MS) return () => clearTimeout(timeout) - }, [searchString, tagIdList]) + }, [searchString, selectedTagIds]) - const handleAdd = () => { - navigate(getRecipeAddUrl()) + const tagFilterModel: FilterDropdownModel = { + placeholder: "Kategorie", + options: tagOptions, + selectedIds: selectedTagIds, + onSelectionChanged: setSelectedTagIds, } if (!recipeList) { @@ -54,9 +72,10 @@ export default function RecipeListPage() {

Recipes

navigate(getRecipeAddUrl())} onSearchStringChanged={setSearchString} numberOfRecipes={recipeList.length} + tagFilterModel={tagFilterModel} />
{/* Content - List of recipe cards */} diff --git a/frontend/src/components/recipes/RecipeListToolbar.tsx b/frontend/src/components/recipes/RecipeListToolbar.tsx index 2622b0b..57d382d 100644 --- a/frontend/src/components/recipes/RecipeListToolbar.tsx +++ b/frontend/src/components/recipes/RecipeListToolbar.tsx @@ -1,35 +1,59 @@ -import { ButtonType } from "../basics/BasicButtonDefinitions" +import {ButtonType} from "../basics/BasicButtonDefinitions" import Button from "../basics/Button" import SearchField from "../basics/SearchField" +import FilterDropdown, {type FilterDropdownModel} from "../basics/FilterDropDown" /** - * Toolbar for RecipeListPage containing searchfield, add recipe button and number of recipes + * Toolbar for RecipeListPage containing tag filter, search field, add button and recipe count. + * + * Single-line (wide): [12 Recipes] [Tags ˅] [Search ×] [Add recipe] + * Two-line (narrow): [12 Recipes] [Search ×] [Add recipe] + * [Tags ˅ — same width as above] */ -type RecepeListToolbarProps = { - onSearchStringChanged: (searchString : string) => void +type RecipeListToolbarProps = { + onSearchStringChanged: (searchString: string) => void onAddClicked: () => void - numberOfRecipes : number + numberOfRecipes: number + tagFilterModel: FilterDropdownModel } -export default function RecipeListToolbar({onSearchStringChanged, onAddClicked, numberOfRecipes} : RecepeListToolbarProps){ +export default function RecipeListToolbar({ + onSearchStringChanged, + onAddClicked, + numberOfRecipes, + tagFilterModel, + }: RecipeListToolbarProps) { return ( -
- {/* Label: left-aligned on medium+ screens, full-width on small screens */} -
- +
+ + {/* Recipe count — left side, grows to push right-side group to the edge */} +
+
- {/* Search + Add button container: right-aligned on medium+ screens */} -
-
- + {/* Right-side group: wraps internally so Tags falls under Search+Button + and inherits exactly the same width */} +
+ + {/* Tags — order-2 so it wraps to row 2 below Search+Button */} +
+
-
+
)