Optimize servings control
This commit is contained in:
parent
e6ea18bef8
commit
f980d4d86d
4 changed files with 220 additions and 166 deletions
|
|
@ -1,102 +1,115 @@
|
|||
/* Import Tailwind layers */
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss";
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom recipe app styles */
|
||||
@layer components{
|
||||
@layer components {
|
||||
|
||||
/* background */
|
||||
.app-bg {
|
||||
@apply flex items-center w-screen justify-center min-h-screen bg-gray-50;
|
||||
}
|
||||
/* background */
|
||||
.app-bg {
|
||||
@apply flex items-center w-screen justify-center min-h-screen bg-gray-50;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
@apply bg-gray-100 w-full min-h-screen max-w-6xl shadow-xl p-8
|
||||
}
|
||||
.content-container {
|
||||
@apply bg-gray-100 w-full min-h-screen max-w-6xl shadow-xl p-8;
|
||||
}
|
||||
|
||||
/* headings */
|
||||
.content-title{
|
||||
@apply text-3xl font-black mb-8 text-blue-900;
|
||||
}
|
||||
/* headings */
|
||||
.sticky-header {
|
||||
@apply sticky bg-gray-100 top-0 left-0 right-0 pb-6 border-b-2 border-gray-300;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
@apply text-xl font-bold mb-2;
|
||||
}
|
||||
.content-title {
|
||||
@apply text-3xl font-black mb-8 text-blue-900;
|
||||
}
|
||||
|
||||
.subsection-heading {
|
||||
@apply font-semibold mb-2 mt-4;
|
||||
}
|
||||
.section-heading {
|
||||
@apply text-xl font-bold mb-2;
|
||||
}
|
||||
|
||||
/* icons */
|
||||
.default-icon {
|
||||
@apply text-gray-400 hover:text-gray-500;
|
||||
}
|
||||
.subsection-heading {
|
||||
@apply font-semibold mb-2 mt-4;
|
||||
}
|
||||
|
||||
/* icons */
|
||||
.default-icon {
|
||||
@apply text-gray-400 hover:text-gray-500;
|
||||
}
|
||||
|
||||
|
||||
/* labels */
|
||||
.label {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
/* labels */
|
||||
.label {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
|
||||
/* errors */
|
||||
.error-text {
|
||||
@apply text-sm text-red-600;
|
||||
}
|
||||
/* errors */
|
||||
.error-text {
|
||||
@apply text-sm text-red-600;
|
||||
}
|
||||
|
||||
/* buttons */
|
||||
.basic-button{
|
||||
@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;
|
||||
}
|
||||
/* buttons */
|
||||
.basic-button {
|
||||
@apply px-4 py-2 shadow-md rounded-lg whitespace-nowrap;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.default-button-bg {
|
||||
@apply bg-gray-300 hover:bg-gray-400;
|
||||
}
|
||||
|
||||
/* groups */
|
||||
.button-group{
|
||||
@apply flex gap-4 mt-4;
|
||||
}
|
||||
.default-button-text {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
|
||||
.horizontal-input-group{
|
||||
@apply flex gap-2 mb-2 items-center;
|
||||
}
|
||||
.primary-button-bg {
|
||||
@apply bg-blue-300 hover:bg-blue-400;
|
||||
}
|
||||
|
||||
/* lists */
|
||||
.default-list {
|
||||
@apply list-disc pl-6 mb-6;
|
||||
}
|
||||
.primary-button-text {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
|
||||
.ingredient-group-card {
|
||||
@apply py-4 border-b border-gray-400;
|
||||
}
|
||||
.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 */
|
||||
.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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ export default function RecipeDetailPage() {
|
|||
{/* Container defining the maximum width of the content */}
|
||||
<div className="content-container">
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -93,19 +93,44 @@ export default function RecipeDetailPage() {
|
|||
)}
|
||||
|
||||
{/* Servings */}
|
||||
<div className="flex flex-row items-center gap-2 bg-blue-100 columns-2 rounded p-2 mb-4">
|
||||
<p className="mb-2">For {recipeWorkingCopy.servings.amount} {recipeWorkingCopy.servings.unit}</p>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field w-20 ml-auto"
|
||||
value={recipeWorkingCopy.servings.amount}
|
||||
onChange={
|
||||
e => {
|
||||
recalculateIngredients(Number(e.target.value))
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 bg-gray-200 rounded p-3 mb-4">
|
||||
<p>
|
||||
Für {recipeWorkingCopy.servings.amount} {recipeWorkingCopy.servings.unit}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-end sm:justify-center gap-2">
|
||||
{/* Minus button */}
|
||||
<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>
|
||||
|
||||
{/* Ingredients */}
|
||||
<h2 className="section-heading">Zutaten</h2>
|
||||
<ul>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import {useEffect, useState} from "react"
|
||||
import RecipeListItem from "./RecipeListItem"
|
||||
import type { RecipeModel } from "../../models/RecipeModel"
|
||||
import { fetchRecipeList } from "../../api/points/CompactRecipePoint"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl } from "../../routes"
|
||||
import type {RecipeModel} from "../../models/RecipeModel"
|
||||
import {fetchRecipeList} from "../../api/points/CompactRecipePoint"
|
||||
import {useNavigate} from "react-router-dom"
|
||||
import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
|
||||
import RecipeListToolbar from "./RecipeListToolbar"
|
||||
|
||||
/**
|
||||
|
|
@ -12,58 +12,61 @@ import RecipeListToolbar from "./RecipeListToolbar"
|
|||
*/
|
||||
export default function RecipeListPage() {
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [recipeList, setRecipeList] = useState<RecipeModel[]|null>(null)
|
||||
const [searchString, setSearchString] = useState<string>("")
|
||||
// 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 navigate = useNavigate()
|
||||
const [recipeList, setRecipeList] = useState<RecipeModel[] | null>(null)
|
||||
const [searchString, setSearchString] = useState<string>("")
|
||||
// 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
|
||||
useEffect(() => {
|
||||
console.log("loading recipe list with searchString", searchString)
|
||||
const loadRecipeList = async () => {
|
||||
try {
|
||||
// Fetch recipe list
|
||||
const data = await fetchRecipeList(searchString)
|
||||
// @todo add and use compact recipe mapper
|
||||
setRecipeList(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
loadRecipeList()
|
||||
}, [,searchString])
|
||||
|
||||
const handleAdd = () => {
|
||||
navigate(getRecipeAddUrl())
|
||||
}
|
||||
console.log("loading recipe list with searchString", searchString)
|
||||
const loadRecipeList = async () => {
|
||||
try {
|
||||
// Fetch recipe list
|
||||
const data = await fetchRecipeList(searchString)
|
||||
// @todo add and use compact recipe mapper
|
||||
setRecipeList(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
loadRecipeList()
|
||||
}, [, searchString])
|
||||
|
||||
if(!recipeList) { return <div>Loading!</div>}
|
||||
return (
|
||||
/*Container spanning entire screen used to center content horizontally */
|
||||
<div className="app-bg">
|
||||
{/* Container defining the maximum width of the content */}
|
||||
<div className="content-container">
|
||||
{/* 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">
|
||||
<h1 className="content-title">Recipes</h1>
|
||||
<RecipeListToolbar
|
||||
onAddClicked={handleAdd}
|
||||
onSearchStringChanged={setSearchString}
|
||||
numberOfRecipes={recipeList.length}
|
||||
/>
|
||||
const handleAdd = () => {
|
||||
navigate(getRecipeAddUrl())
|
||||
}
|
||||
|
||||
if (!recipeList) {
|
||||
return <div>Loading!</div>
|
||||
}
|
||||
return (
|
||||
/*Container spanning entire screen used to center content horizontally */
|
||||
<div className="app-bg">
|
||||
{/* Container defining the maximum width of the content */}
|
||||
<div className="content-container">
|
||||
{/* 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>
|
||||
{/*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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,42 @@
|
|||
@import "tailwindcss";
|
||||
@import "tailwindcss/utilities";
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
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 {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue