Optimize servings control

This commit is contained in:
araemer 2025-10-21 07:36:49 +02:00
parent e6ea18bef8
commit f980d4d86d
4 changed files with 220 additions and 166 deletions

View file

@ -1,102 +1,115 @@
/* Import Tailwind layers */ /* Import Tailwind layers */
@import "tailwindcss"; @import "tailwindcss";
@tailwind utilities; @tailwind utilities;
/* Custom recipe app styles */ /* Custom recipe app styles */
@layer components{ @layer components {
/* background */ /* background */
.app-bg { .app-bg {
@apply flex items-center w-screen justify-center min-h-screen bg-gray-50; @apply flex items-center w-screen justify-center min-h-screen bg-gray-50;
} }
.content-container { .content-container {
@apply bg-gray-100 w-full min-h-screen max-w-6xl shadow-xl p-8 @apply bg-gray-100 w-full min-h-screen max-w-6xl shadow-xl p-8;
} }
/* headings */ /* headings */
.content-title{ .sticky-header {
@apply text-3xl font-black mb-8 text-blue-900; @apply sticky bg-gray-100 top-0 left-0 right-0 pb-6 border-b-2 border-gray-300;
} }
.section-heading { .content-title {
@apply text-xl font-bold mb-2; @apply text-3xl font-black mb-8 text-blue-900;
} }
.subsection-heading { .section-heading {
@apply font-semibold mb-2 mt-4; @apply text-xl font-bold mb-2;
} }
/* icons */ .subsection-heading {
.default-icon { @apply font-semibold mb-2 mt-4;
@apply text-gray-400 hover:text-gray-500; }
}
/* icons */
.default-icon {
@apply text-gray-400 hover:text-gray-500;
}
/* labels */ /* labels */
.label { .label {
@apply text-gray-600; @apply text-gray-600;
} }
/* errors */ /* errors */
.error-text { .error-text {
@apply text-sm text-red-600; @apply text-sm text-red-600;
} }
/* buttons */ /* buttons */
.basic-button{ .basic-button {
@apply px-4 py-2 shadow-md rounded-lg whitespace-nowrap; @apply px-4 py-2 shadow-md rounded-lg whitespace-nowrap;
} }
.default-button-bg {
@apply bg-gray-300 hover:bg-gray-400;
}
.default-button-text{
@apply text-gray-600;
}
.primary-button-bg {
@apply bg-blue-300 hover:bg-blue-400;
}
.primary-button-text {
@apply text-gray-600;
}
.dark-button-bg{
@apply bg-gray-600 hover:bg-gray-800;
}
.dark-button-text{
@apply text-white;
}
.transparent-button-bg {
@apply bg-transparent hover:bg-transparent;
}
.transparent-button-text {
@apply text-gray-600;
}
/* input fields like input and textarea */ .default-button-bg {
.input-field { @apply bg-gray-300 hover:bg-gray-400;
@apply p-2 w-full border rounded-md bg-white placeholder-gray-400 border-gray-600 hover:border-blue-800 transition-colors text-gray-600 focus:outline-none focus:border-blue-900; }
}
/* groups */ .default-button-text {
.button-group{ @apply text-gray-600;
@apply flex gap-4 mt-4; }
}
.horizontal-input-group{ .primary-button-bg {
@apply flex gap-2 mb-2 items-center; @apply bg-blue-300 hover:bg-blue-400;
} }
/* lists */ .primary-button-text {
.default-list { @apply text-gray-600;
@apply list-disc pl-6 mb-6; }
}
.ingredient-group-card { .dark-button-bg {
@apply py-4 border-b border-gray-400; @apply bg-gray-600 hover:bg-gray-800;
} }
.dark-button-text {
@apply text-white;
}
.transparent-button-bg {
@apply bg-transparent hover:bg-transparent;
}
.transparent-button-text {
@apply text-gray-600;
}
/* input fields like input and textarea */
.input-field {
@apply p-2 w-full border rounded-md bg-white placeholder-gray-400 border-gray-600 hover:border-blue-800 transition-colors text-gray-600 focus:outline-none focus:border-blue-900;
}
/* groups */
.button-group {
@apply flex gap-4 mt-4;
}
.horizontal-input-group {
@apply flex gap-2 mb-2 items-center;
}
/* lists */
.default-list {
@apply list-disc pl-6 mb-6;
}
.ingredient-group-card {
@apply py-4 border-b border-gray-400;
}
.enumeration-indicator {
@apply flex-shrink-0 w-7 h-7 rounded-full bg-blue-300 text-white flex items-center justify-center shadow-sm;
}
.enumeration-indicator{
@apply flex-shrink-0 w-7 h-7 rounded-full bg-blue-300 text-white flex items-center justify-center shadow-sm;
}
} }

View file

@ -77,7 +77,7 @@ export default function RecipeDetailPage() {
{/* Container defining the maximum width of the content */} {/* Container defining the maximum width of the content */}
<div className="content-container"> <div className="content-container">
{/* Header - remains in position when scrolling */} {/* Header - remains in position when scrolling */}
<div className="sticky bg-gray-100 top-0 left-0 right-0 pb-6 border-b-2 border-gray-300"> <div className="sticky-header">
<h1 className="content-title mb-0">{recipeWorkingCopy.title}</h1> <h1 className="content-title mb-0">{recipeWorkingCopy.title}</h1>
</div> </div>
@ -93,19 +93,44 @@ export default function RecipeDetailPage() {
)} )}
{/* Servings */} {/* Servings */}
<div className="flex flex-row items-center gap-2 bg-blue-100 columns-2 rounded p-2 mb-4"> <div
<p className="mb-2">For {recipeWorkingCopy.servings.amount} {recipeWorkingCopy.servings.unit}</p> className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 bg-gray-200 rounded p-3 mb-4">
<input <p>
type="number" Für {recipeWorkingCopy.servings.amount} {recipeWorkingCopy.servings.unit}
className="input-field w-20 ml-auto" </p>
value={recipeWorkingCopy.servings.amount}
onChange={ <div className="flex items-center justify-end sm:justify-center gap-2">
e => { {/* Minus button */}
recalculateIngredients(Number(e.target.value)) <button
} type="button"
} onClick={() => recalculateIngredients(Math.max(1, recipeWorkingCopy.servings.amount - 1))}
/> className="enumeration-indicator primary-button-bg"
>
</button>
{/* Number input (no spin buttons) */}
<input
type="number"
inputMode="numeric"
pattern="[0-9]*"
className="w-16 text-center input-field"
value={recipeWorkingCopy.servings.amount}
onChange={e => recalculateIngredients(Number(e.target.value))}
min={1}
/>
{/* Plus button */}
<button
type="button"
onClick={() => recalculateIngredients(recipeWorkingCopy.servings.amount + 1)}
className="enumeration-indicator primary-button-bg"
>
+
</button>
</div>
</div> </div>
{/* Ingredients */} {/* Ingredients */}
<h2 className="section-heading">Zutaten</h2> <h2 className="section-heading">Zutaten</h2>
<ul> <ul>

View file

@ -1,9 +1,9 @@
import { useEffect, useState } from "react" import {useEffect, useState} from "react"
import RecipeListItem from "./RecipeListItem" import RecipeListItem from "./RecipeListItem"
import type { RecipeModel } from "../../models/RecipeModel" import type {RecipeModel} from "../../models/RecipeModel"
import { fetchRecipeList } from "../../api/points/CompactRecipePoint" import {fetchRecipeList} from "../../api/points/CompactRecipePoint"
import { useNavigate } from "react-router-dom" import {useNavigate} from "react-router-dom"
import { getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl } from "../../routes" import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
import RecipeListToolbar from "./RecipeListToolbar" import RecipeListToolbar from "./RecipeListToolbar"
/** /**
@ -12,58 +12,61 @@ import RecipeListToolbar from "./RecipeListToolbar"
*/ */
export default function RecipeListPage() { export default function RecipeListPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [recipeList, setRecipeList] = useState<RecipeModel[]|null>(null) const [recipeList, setRecipeList] = useState<RecipeModel[] | null>(null)
const [searchString, setSearchString] = useState<string>("") const [searchString, setSearchString] = useState<string>("")
// load recipes once on render and whenever search string changes // 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 // @todo add delay. Only reload list if the search string hasn't changed for ~200 ms
useEffect(() => { useEffect(() => {
console.log("loading recipe list with searchString", searchString) console.log("loading recipe list with searchString", searchString)
const loadRecipeList = async () => { const loadRecipeList = async () => {
try { try {
// Fetch recipe list // Fetch recipe list
const data = await fetchRecipeList(searchString) const data = await fetchRecipeList(searchString)
// @todo add and use compact recipe mapper // @todo add and use compact recipe mapper
setRecipeList(data) setRecipeList(data)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
} }
loadRecipeList() loadRecipeList()
}, [,searchString]) }, [, searchString])
const handleAdd = () => {
navigate(getRecipeAddUrl())
}
if(!recipeList) { return <div>Loading!</div>} const handleAdd = () => {
return ( navigate(getRecipeAddUrl())
/*Container spanning entire screen used to center content horizontally */ }
<div className="app-bg">
{/* Container defining the maximum width of the content */} if (!recipeList) {
<div className="content-container"> return <div>Loading!</div>
{/* Header - remains in position when scrolling */} }
<div className="sticky bg-gray-100 top-0 left-0 right-0 pb-4 border-b-2 border-gray-300"> return (
<h1 className="content-title">Recipes</h1> /*Container spanning entire screen used to center content horizontally */
<RecipeListToolbar <div className="app-bg">
onAddClicked={handleAdd} {/* Container defining the maximum width of the content */}
onSearchStringChanged={setSearchString} <div className="content-container">
numberOfRecipes={recipeList.length} {/* Header - remains in position when scrolling */}
/> <div className="sticky-header">
<h1 className="content-title">Recipes</h1>
<RecipeListToolbar
onAddClicked={handleAdd}
onSearchStringChanged={setSearchString}
numberOfRecipes={recipeList.length}
/>
</div>
{/*Content - List of recipe cards */}
<div className="w-full pt-4">
<div
className="grid gap-6 grid-cols-[repeat(auto-fit,minmax(220px,auto))] max-w-6xl mx-auto justify-center">
{recipeList.map((recipe) => (
<RecipeListItem
key={recipe.id}
title={recipe.title}
targetPath={recipe.id !== undefined ? getRecipeDetailUrl(recipe.id) : getRecipeListUrl()} // @todo proper error handling
/>
))}
</div>
</div>
</div>
</div> </div>
{/*Content - List of recipe cards */} )
<div className="w-full pt-4">
<div className="grid gap-6 grid-cols-[repeat(auto-fit,minmax(220px,auto))] max-w-6xl mx-auto justify-center">
{recipeList.map((recipe) => (
<RecipeListItem
key={recipe.id}
title={recipe.title}
targetPath={recipe.id !== undefined ? getRecipeDetailUrl(recipe.id) : getRecipeListUrl()} // @todo proper error handling
/>
))}
</div>
</div>
</div>
</div>
)
} }

View file

@ -1,29 +1,42 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tailwindcss/utilities"; @import "tailwindcss/utilities";
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark; color-scheme: light dark;
color: rgba(255, 255, 255, 0.87); color: rgba(255, 255, 255, 0.87);
background-color: #242424; background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
body { body {
margin: 0; margin: 0;
display: flex; display: flex;
place-items: center; place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
} }
/* Hide number input spinners (Chrome, Safari, Edge, Opera) */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Hide number input spinners in Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
/*a { /*a {
font-weight: 500; font-weight: 500;
color: #646cff; color: #646cff;