Compare commits
2 commits
userManage
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
709cb23f3d | ||
|
|
0dc3264a22 |
17 changed files with 353 additions and 128 deletions
|
|
@ -90,8 +90,6 @@ export const apiClient = {
|
|||
get: <T>(endpoint: string) => apiRequest<T>(endpoint, {method: "GET"}),
|
||||
post: <T>(endpoint: string, body: object) =>
|
||||
apiRequest<T>(endpoint, {method: "POST", body: JSON.stringify(body)}),
|
||||
put: <T>(endpoint: string, body: object) =>
|
||||
apiRequest<T>(endpoint, {method: "PUT", body: JSON.stringify(body)}),
|
||||
delete: <T>(endpoint: string) =>
|
||||
apiRequest<T>(endpoint, {method: "DELETE"}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
8
frontend/src/api/dtos/TagDto.ts
Normal file
8
frontend/src/api/dtos/TagDto.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import {AbstractDto} from "./AbstractDto.ts";
|
||||
|
||||
/**
|
||||
* DTO describing a tag
|
||||
*/
|
||||
export class TagDto extends AbstractDto {
|
||||
description!: string;
|
||||
}
|
||||
|
|
@ -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<LoginResponse> {
|
||||
const res = await postJson(`${AUTH_URL}/login`, JSON.stringify(loginRequest), false);
|
||||
return res.json();
|
||||
return apiClient.post(`${AUTH_URL}/login`, loginRequest);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RecipeModel[]> {
|
||||
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<RecipeModel[]> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RecipeDto> {
|
||||
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<RecipeDto> {
|
|||
* @returns Saved recipe
|
||||
*/
|
||||
export async function createOrUpdateRecipe(recipe: RecipeDto): Promise<RecipeDto> {
|
||||
const res = await postJson(RECIPE_URL + "/create-or-update", JSON.stringify(recipe));
|
||||
return res.json();
|
||||
return apiClient.post(`${RECIPE_URL}/create-or-update`, recipe);
|
||||
}
|
||||
|
|
|
|||
27
frontend/src/api/endpoints/TagRestResource.ts
Normal file
27
frontend/src/api/endpoints/TagRestResource.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import {apiClient} from "../apiClient"
|
||||
import type {TagDto} from "../dtos/TagDto"
|
||||
|
||||
const TAG_URL = `/tag`
|
||||
|
||||
/**
|
||||
* Fetches all existing tags from the backend.
|
||||
*/
|
||||
export async function fetchAllTags(): Promise<TagDto[]> {
|
||||
return apiClient.get<TagDto[]>(`${TAG_URL}/all`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tag or updates an existing one.
|
||||
* @param tag The tag to create or update
|
||||
*/
|
||||
export async function createOrUpdateTag(tag: TagDto): Promise<TagDto> {
|
||||
return apiClient.post<TagDto>(`${TAG_URL}/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<void> {
|
||||
return apiClient.delete(`${TAG_URL}/${id}`)
|
||||
}
|
||||
|
|
@ -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<UserDto> {
|
||||
return apiClient.get("/user/me");
|
||||
return apiClient.get(`${USER_URL}/me`);
|
||||
}
|
||||
|
||||
export async function fetchAllUsers(): Promise<UserListResponse> {
|
||||
return apiClient.get("/user/all");
|
||||
return apiClient.get(`${USER_URL}/all`);
|
||||
}
|
||||
|
||||
export async function createUser(dto: CreateUserRequest): Promise<CreateUserResponse> {
|
||||
return apiClient.post("/user/create", dto);
|
||||
return apiClient.post(`${USER_URL}/create`, dto);
|
||||
}
|
||||
|
||||
export async function updateUser(dto: UserDto): Promise<UserDto> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <div>Oops, there's no recipe in RecipeEditor...</div>
|
||||
// @todo add handling of images
|
||||
|
||||
return (
|
||||
/*Container spanning entire screen used to center content horizontally */
|
||||
<PageContainer>
|
||||
{/* Container defining the maximum width of the content */}
|
||||
<PageContentLayout>
|
||||
<h1 className="border-b-2 border-gray-300 pb-4">
|
||||
{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 */}
|
||||
<h2>Portionen</h2>
|
||||
<div className="columns-3 gap-2 flex items-center">
|
||||
|
|
@ -124,7 +108,15 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Ingredient List - @todo better visualization of errors! */}
|
||||
|
||||
{/* Tags */}
|
||||
<h2>Tags</h2>
|
||||
<TagListEditor
|
||||
tagList={draft.tagList ?? []}
|
||||
onChange={updateTagList}
|
||||
/>
|
||||
|
||||
{/* Ingredient List */}
|
||||
<div className={errors.ingredients ? "border error-text rounded p-2" : ""}>
|
||||
<IngredientGroupListEditor
|
||||
ingredientGroupList={draft.ingredientGroupList}
|
||||
|
|
@ -132,15 +124,13 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Instruction List*/}
|
||||
{/* Instruction List */}
|
||||
<InstructionStepListEditor
|
||||
instructionStepList={draft.instructionStepList}
|
||||
onChange={updateInstructionList}
|
||||
/>
|
||||
|
||||
|
||||
<ButtonGroupLayout>
|
||||
{/* Save Button */}
|
||||
<Button
|
||||
onClick={() => handleSave(draft)}
|
||||
text={"Speichern"}
|
||||
|
|
@ -155,4 +145,4 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
|||
</PageContentLayout>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import {useEffect, useState} from "react"
|
||||
import RecipeListItem from "./RecipeListItem"
|
||||
import type {RecipeModel} from "../../models/RecipeModel"
|
||||
import {fetchRecipeList} from "../../api/points/CompactRecipePoint"
|
||||
import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.ts"
|
||||
import {useNavigate} from "react-router-dom"
|
||||
import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
|
||||
import RecipeListToolbar from "./RecipeListToolbar"
|
||||
|
|
|
|||
26
frontend/src/components/tags/TagChip.tsx
Normal file
26
frontend/src/components/tags/TagChip.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type {TagModel} from "../../models/TagModel.ts"
|
||||
|
||||
type TagChipProps = {
|
||||
tag: TagModel
|
||||
onRemove: (tag: TagModel) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a single tag as a styled chip with a remove (×) button.
|
||||
*/
|
||||
export function TagChip({tag, onRemove}: TagChipProps) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
|
||||
{tag.description}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(tag)}
|
||||
className="ml-1 text-blue-500 hover:text-blue-800 focus:outline-none leading-none"
|
||||
aria-label={`Remove tag ${tag.description}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
161
frontend/src/components/tags/TagListEditor.tsx
Normal file
161
frontend/src/components/tags/TagListEditor.tsx
Normal file
|
|
@ -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 '<text>'" option.
|
||||
*/
|
||||
export function TagListEditor({tagList, onChange}: TagListEditorProps) {
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [availableTags, setAvailableTags] = useState<TagModel[]>([])
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Selected tags — only rendered when at least one tag is present */}
|
||||
{tagList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{tagList.map((tag, index) => (
|
||||
<TagChip key={tag.id ?? index} tag={tag} onRemove={removeTag}/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={isLoading ? "Loading tags…" : "Add tag…"}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && (
|
||||
<ul className="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded shadow-lg max-h-48 overflow-y-auto text-sm">
|
||||
{filteredSuggestions.map(tag => (
|
||||
<li
|
||||
key={tag.id}
|
||||
onMouseDown={() => selectTag(tag)}
|
||||
className="px-3 py-2 cursor-pointer hover:bg-blue-50"
|
||||
>
|
||||
{tag.description}
|
||||
</li>
|
||||
))}
|
||||
{isNewTag && (
|
||||
<li
|
||||
onMouseDown={createNewTag}
|
||||
className="px-3 py-2 cursor-pointer hover:bg-green-50 text-green-700 font-medium border-t border-gray-100"
|
||||
>
|
||||
Create “{inputValue.trim()}”
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/src/mappers/TagMapper.ts
Normal file
22
frontend/src/mappers/TagMapper.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
|
|
|
|||
9
frontend/src/models/TagModel.ts
Normal file
9
frontend/src/models/TagModel.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue