(WIP): Moving to desktop
This commit is contained in:
parent
d18710e1fc
commit
d170429752
17
web/src/components/forms/IngredientItem.tsx
Normal file
17
web/src/components/forms/IngredientItem.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import type { RecipeIngredient } from "../../types/recipe";
|
||||
|
||||
interface IngredientItemProps {
|
||||
ingredient: RecipeIngredient;
|
||||
onChange: (id: string, name: string) => void;
|
||||
}
|
||||
|
||||
export default function IngredientItem({ ingredient, onChange }: IngredientItemProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.Name}
|
||||
onChange={(e) => onChange(ingredient.Id, e.target.value)}
|
||||
placeholder="Ingredient name"
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
web/src/components/forms/IngredientList.tsx
Normal file
59
web/src/components/forms/IngredientList.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { type IngredientId, type IngredientsById, type RecipeIngredient, type RecipeIngredientSection, type SectionsById } from "../../types/recipe";
|
||||
import IngredientSectionElement from "./IngredientSectionElement";
|
||||
import { Reorder } from "motion/react";
|
||||
|
||||
|
||||
interface IngredientListProps {
|
||||
}
|
||||
|
||||
export default function IngredientList({ sectionOrder, setSectionOrder, sectionsById, ingredientsById, setIngredientsById }: IngredientListProps) {
|
||||
const handleIngredientChange = (
|
||||
ingredientId: IngredientId,
|
||||
field: "Name" | "Amount" | "Unit",
|
||||
value: string
|
||||
) => {
|
||||
setIngredientsById(prev => {
|
||||
const ing = prev[ingredientId];
|
||||
if (!ing) return prev;
|
||||
|
||||
const updated: RecipeIngredient = {
|
||||
...ing,
|
||||
[field]:
|
||||
field === "Amount"
|
||||
? (value === "" ? 0 : Number(value))
|
||||
: value,
|
||||
};
|
||||
|
||||
// If nothing changed, keep same reference
|
||||
if (updated === ing) return prev;
|
||||
|
||||
return { ...prev, [ingredientId]: updated };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={sectionOrder}
|
||||
onReorder={setSectionOrder}
|
||||
>
|
||||
{sectionOrder.map(sectionId => {
|
||||
const section = sectionsById[sectionId];
|
||||
console.log("@section", section);
|
||||
return (
|
||||
<Reorder.Item key={sectionId} value={sectionId}>
|
||||
<IngredientSectionElement
|
||||
section={section}
|
||||
ingredientsById={ingredientsById}
|
||||
onChange={handleIngredientChange}
|
||||
/>
|
||||
|
||||
</Reorder.Item>
|
||||
)
|
||||
})}
|
||||
</Reorder.Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
web/src/components/forms/IngredientSection.tsx
Normal file
36
web/src/components/forms/IngredientSection.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import type { DragControls } from "motion/react";
|
||||
import type { RecipeIngredientSection } from "../../types/recipe";
|
||||
import DeleteIconSmall from "../icons/DeleteIconSmall";
|
||||
import DragIconSmall from "../icons/DragIconSmall";
|
||||
|
||||
interface IngredientSectionProps {
|
||||
section: RecipeIngredientSection;
|
||||
onChange: (id: string, name: string) => void;
|
||||
index: number;
|
||||
controls: DragControls;
|
||||
};
|
||||
|
||||
export default function IngredientSection({ section, onChange, index, controls }: IngredientSectionProps) {
|
||||
return (
|
||||
<div className="w-full bg-gray-100 p-3 flex items-center">
|
||||
<p className="font-semibold">Group:</p>
|
||||
<input
|
||||
type="text"
|
||||
value={section.Name}
|
||||
onChange={(e) => onChange(section.Id, e.target.value)}
|
||||
placeholder="Section title"
|
||||
className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm"
|
||||
/>
|
||||
|
||||
<div className="w-1/10 flex justify-between">
|
||||
<button className="cursor-pointer">
|
||||
<DeleteIconSmall />
|
||||
</button>
|
||||
<div className="cursor-pointer" onPointerDown={(e) => controls.start(e)}>
|
||||
<DragIconSmall />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import { useEffect, useState, type ChangeEvent } from "react";
|
||||
import type { Instruction } from "../../pages/Create";
|
||||
import type { RecipeInstruction } from "../../types/recipe";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import DragIconSmall from "../icons/DragIconSmall";
|
||||
import DeleteIconSmall from "../icons/DeleteIconSmall";
|
||||
|
||||
interface InstructionElementProps {
|
||||
instruction: Instruction;
|
||||
instruction: RecipeInstruction;
|
||||
index: number;
|
||||
allowDelete: boolean;
|
||||
onChange: (id: string, value: string) => void;
|
||||
@ -22,13 +23,13 @@ export default function InstructionElement({ instruction, index, allowDelete, on
|
||||
// No need to set many times
|
||||
if (!dirty) setDirty(true);
|
||||
|
||||
onChange(instruction.id, e.target.value);
|
||||
onChange(instruction.Id, e.target.value);
|
||||
}
|
||||
|
||||
// EFFECTS
|
||||
useEffect(() => {
|
||||
if (dirty)
|
||||
setValid(instruction.content !== "");
|
||||
setValid(instruction.Content !== "");
|
||||
}, [dirty, instruction]);
|
||||
|
||||
return (
|
||||
@ -44,7 +45,7 @@ export default function InstructionElement({ instruction, index, allowDelete, on
|
||||
<textarea
|
||||
className="flex-grow border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all min-h-40 md:min-h-26 shadow-sm"
|
||||
name="instructions"
|
||||
value={instruction.content}
|
||||
value={instruction.Content}
|
||||
onChange={changeHandler}
|
||||
rows={3}
|
||||
required
|
||||
@ -67,12 +68,10 @@ export default function InstructionElement({ instruction, index, allowDelete, on
|
||||
<button
|
||||
tabIndex={-1}
|
||||
disabled={!allowDelete}
|
||||
onClick={() => onDelete(instruction.id)}
|
||||
onClick={() => onDelete(instruction.Id)}
|
||||
className="p-2 pr-0 cursor-pointer text-gray-500 hover:text-red-500 disabled:text-gray-200 disabled:cursor-not-allowed duration-300"
|
||||
>
|
||||
<svg className="size-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.0004 9.5L17.0004 14.5M17.0004 9.5L12.0004 14.5M4.50823 13.9546L7.43966 17.7546C7.79218 18.2115 7.96843 18.44 8.18975 18.6047C8.38579 18.7505 8.6069 18.8592 8.84212 18.9253C9.10766 19 9.39623 19 9.97336 19H17.8004C18.9205 19 19.4806 19 19.9084 18.782C20.2847 18.5903 20.5907 18.2843 20.7824 17.908C21.0004 17.4802 21.0004 16.9201 21.0004 15.8V8.2C21.0004 7.0799 21.0004 6.51984 20.7824 6.09202C20.5907 5.71569 20.2847 5.40973 19.9084 5.21799C19.4806 5 18.9205 5 17.8004 5H9.97336C9.39623 5 9.10766 5 8.84212 5.07467C8.6069 5.14081 8.38579 5.2495 8.18975 5.39534C7.96843 5.55998 7.79218 5.78846 7.43966 6.24543L4.50823 10.0454C3.96863 10.7449 3.69883 11.0947 3.59505 11.4804C3.50347 11.8207 3.50347 12.1793 3.59505 12.5196C3.69883 12.9053 3.96863 13.2551 4.50823 13.9546Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<DeleteIconSmall />
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
|
||||
@ -1,25 +1,26 @@
|
||||
import { Reorder } from "motion/react";
|
||||
import type { Instruction } from "../../pages/Create";
|
||||
import InstructionElement from "./InstructionElement";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { RecipeInstruction } from "../../types/recipe";
|
||||
|
||||
|
||||
interface InstructionFormProps {
|
||||
instructions: Instruction[];
|
||||
setInstructions: React.Dispatch<React.SetStateAction<Instruction[]>>
|
||||
interface InstructionListProps {
|
||||
instructions: RecipeInstruction[];
|
||||
setInstructions: Dispatch<SetStateAction<RecipeInstruction[]>>;
|
||||
}
|
||||
|
||||
export default function InstructionForm({ instructions, setInstructions }: InstructionFormProps) {
|
||||
export default function InstructionList({ instructions, setInstructions }: InstructionListProps) {
|
||||
const handleChange = (id: string, value: string) => {
|
||||
setInstructions(prev =>
|
||||
prev.map(instr =>
|
||||
instr.id === id ? { ...instr, content: value } : instr
|
||||
instr.Id === id ? { ...instr, Content: value } : instr
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setInstructions(prev =>
|
||||
prev.filter(instr => instr.id !== id)
|
||||
prev.filter(instr => instr.Id !== id)
|
||||
);
|
||||
}
|
||||
|
||||
@ -32,7 +33,7 @@ export default function InstructionForm({ instructions, setInstructions }: Instr
|
||||
>
|
||||
{instructions.map((instruction, i) => (
|
||||
<InstructionElement
|
||||
key={instruction.id}
|
||||
key={instruction.Id}
|
||||
index={i}
|
||||
instruction={instruction}
|
||||
allowDelete={instructions.length > 1}
|
||||
7
web/src/components/icons/DeleteIconSmall.tsx
Normal file
7
web/src/components/icons/DeleteIconSmall.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function DeleteIconSmall() {
|
||||
return (
|
||||
<svg className="size-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.0004 9.5L17.0004 14.5M17.0004 9.5L12.0004 14.5M4.50823 13.9546L7.43966 17.7546C7.79218 18.2115 7.96843 18.44 8.18975 18.6047C8.38579 18.7505 8.6069 18.8592 8.84212 18.9253C9.10766 19 9.39623 19 9.97336 19H17.8004C18.9205 19 19.4806 19 19.9084 18.782C20.2847 18.5903 20.5907 18.2843 20.7824 17.908C21.0004 17.4802 21.0004 16.9201 21.0004 15.8V8.2C21.0004 7.0799 21.0004 6.51984 20.7824 6.09202C20.5907 5.71569 20.2847 5.40973 19.9084 5.21799C19.4806 5 18.9205 5 17.8004 5H9.97336C9.39623 5 9.10766 5 8.84212 5.07467C8.6069 5.14081 8.38579 5.2495 8.18975 5.39534C7.96843 5.55998 7.79218 5.78846 7.43966 6.24543L4.50823 10.0454C3.96863 10.7449 3.69883 11.0947 3.59505 11.4804C3.50347 11.8207 3.50347 12.1793 3.59505 12.5196C3.69883 12.9053 3.96863 13.2551 4.50823 13.9546Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,14 @@
|
||||
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import { Fragment, useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import Banner from "../components/Banner";
|
||||
import { isRecipeMeal } from "../types/recipe";
|
||||
import InstructionForm from "../components/forms/InstructionForm";
|
||||
import { isRecipeMeal, RecipeIngredient, type IngredientsById, type RecipeIngredientSection, type RecipeInstruction, type SectionId, type SectionsById } from "../types/recipe";
|
||||
import InstructionList from "../components/forms/InstructionList";
|
||||
import ValidationErrorList from "../components/forms/ValidationErrorList";
|
||||
import IngredientSection from "../components/forms/IngredientSection";
|
||||
import { section } from "motion/react-client";
|
||||
import IngredientItem from "../components/forms/IngredientItem";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
|
||||
// TODO: Move this
|
||||
interface CreateRecipeForm {
|
||||
title: string;
|
||||
description: string;
|
||||
@ -19,6 +24,7 @@ interface CreateRecipeForm {
|
||||
image: File | null;
|
||||
};
|
||||
|
||||
// TODO: Move this
|
||||
export interface CreateRecipeFormToggles {
|
||||
title: boolean;
|
||||
description: boolean;
|
||||
@ -32,11 +38,6 @@ export interface CreateRecipeFormToggles {
|
||||
// TODO: Image
|
||||
}
|
||||
|
||||
export interface Instruction {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classes which are applied to all of the input elements.
|
||||
*/
|
||||
@ -58,7 +59,40 @@ export default function Create() {
|
||||
image: null,
|
||||
});
|
||||
// Store complex values elsewhere
|
||||
const [instructions, setInstructions] = useState<Instruction[]>([{ id: crypto.randomUUID(), content: "" }, { id: crypto.randomUUID(), content: "" }]);
|
||||
const [instructions, setInstructions] = useState<RecipeInstruction[]>([{ Id: crypto.randomUUID(), Content: "" }, { Id: crypto.randomUUID(), Content: "" }]);
|
||||
|
||||
// Ingredients
|
||||
const [sections, setSections] = useState<RecipeIngredientSection[]>([
|
||||
{ Id: "a", Name: "Section 1" },
|
||||
{ Id: "b", Name: "Section 2" },
|
||||
{ Id: "c", Name: "Section 3" }
|
||||
]);
|
||||
|
||||
const [ingredients, setIngredients] = useState<RecipeIngredient[]>([
|
||||
{ Id: crypto.randomUUID(), SectionId: "a", Name: "Ingredient 1", Amount: 1, Unit: "lb" },
|
||||
{ Id: crypto.randomUUID(), SectionId: "a", Name: "Ingredient 2", Amount: 1, Unit: "lb" },
|
||||
{ Id: crypto.randomUUID(), SectionId: "b", Name: "Ingredient 3", Amount: 1, Unit: "lb" },
|
||||
{ Id: crypto.randomUUID(), SectionId: "c", Name: "Ingredient 4", Amount: 1, Unit: "lb" },
|
||||
{ Id: crypto.randomUUID(), SectionId: "c", Name: "Ingredient 5", Amount: 1, Unit: "lb" },
|
||||
{ Id: crypto.randomUUID(), SectionId: "c", Name: "Ingredient 6", Amount: 1, Unit: "lb" },
|
||||
]);
|
||||
|
||||
const sectionChangeHandler = (id: string, name: string) => {
|
||||
setSections(prev =>
|
||||
prev.map(section =>
|
||||
section.Id === id ? { ...section, Name: name } : section
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const ingredientChangeHandler = (id: string, name: string) => {
|
||||
setIngredients(prev =>
|
||||
prev.map(ing =>
|
||||
ing.Id === id ? { ...ing, Name: name } : ing
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// VALIDATION STATE
|
||||
const [validation, setValidation] = useState<CreateRecipeFormToggles>({
|
||||
@ -97,7 +131,7 @@ export default function Create() {
|
||||
|
||||
state.category = dirty.category ? (inputs.category !== "" && isRecipeMeal(inputs.category)) : true;
|
||||
state.difficulty = dirty.difficulty ? (inputs.difficulty !== "" && Number(inputs.difficulty) >= 1 && Number(inputs.difficulty) <= 5) : true;
|
||||
state.instructions = instructions?.filter(x => x.content === "").length === 0; // All of them are not empty
|
||||
// state.instructions = instructions?.filter(x => x.content === "").length === 0; // All of them are not empty
|
||||
|
||||
setValidation(state);
|
||||
}
|
||||
@ -149,7 +183,7 @@ export default function Create() {
|
||||
}
|
||||
|
||||
const addInstructionHandler = () => {
|
||||
setInstructions([...instructions, { id: crypto.randomUUID(), content: "" }]);
|
||||
setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
|
||||
}
|
||||
|
||||
|
||||
@ -242,7 +276,7 @@ export default function Create() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TODO: Tags Input */}
|
||||
{/* Tag Input */}
|
||||
<form onSubmit={tagCreationHandler} className="my-4 flex flex-col gap-x-2">
|
||||
<div className="flex flex-col flex-grow">
|
||||
<label htmlFor="tags" className="text-sm">
|
||||
@ -415,50 +449,36 @@ export default function Create() {
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs py-1 text-gray-700">Please provide a list of ingredients and their quantities.</p>
|
||||
<ul id="ingredient-list">
|
||||
<li className="w-full flex gap-x-2 py-2">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
className="peer w-full border border-gray-300 px-4 py-2 rounded-lg focus:outline-none
|
||||
focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm
|
||||
invalid:border-red-500"
|
||||
type="text"
|
||||
id="ingredients"
|
||||
name="ingredients"
|
||||
required
|
||||
minLength={1}
|
||||
placeholder="Ingredient name (e.g., Chicken Breast)"
|
||||
/>
|
||||
<p className="text-xs text-red-500 my-1">
|
||||
Please enter at least one ingredient.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<input
|
||||
className="peer w-full border border-gray-300 px-4 py-2 rounded-lg focus:outline-none
|
||||
focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm
|
||||
invalid:border-red-500"
|
||||
type="text"
|
||||
id="quantity"
|
||||
name="quantity"
|
||||
required
|
||||
minLength={1}
|
||||
placeholder="Quantity (e.g., 1lb)"
|
||||
/>
|
||||
<p className="text-xs text-red-500 my-1">
|
||||
Please provide a quantity.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="text-base md:text-lg text-white bg-blue-500 w-fit px-5 py-2 rounded-lg cursor-pointer"
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={sections}
|
||||
onReorder={setSections}
|
||||
className=""
|
||||
>
|
||||
Add Ingredient
|
||||
</button>
|
||||
{sections.map((section, i) => {
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={section.Id}
|
||||
value={section}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
className="select-none"
|
||||
>
|
||||
<IngredientSection key={section.Id} section={section} onChange={sectionChangeHandler} index={i} controls={controls} />
|
||||
{ingredients.filter(x => x.SectionId === section.Id).map(ing =>
|
||||
<IngredientItem key={ing.Id} ingredient={ing} onChange={ingredientChangeHandler} />
|
||||
)}
|
||||
</Reorder.Item>
|
||||
)
|
||||
})}
|
||||
</Reorder.Group>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Instruction Inputs */}
|
||||
<div className="flex flex-col my-4">
|
||||
<label htmlFor="instructions" className="text-sm">
|
||||
Instructions
|
||||
@ -468,7 +488,7 @@ export default function Create() {
|
||||
Please provide a list of instructions. You do not need to include step number, they will be added automatically!
|
||||
</p>
|
||||
|
||||
<InstructionForm instructions={instructions} setInstructions={setInstructions} />
|
||||
<InstructionList instructions={instructions} setInstructions={setInstructions} />
|
||||
|
||||
<button
|
||||
onClick={addInstructionHandler}
|
||||
|
||||
@ -5,16 +5,7 @@ export interface RecipeDuration {
|
||||
Cook: number;
|
||||
}
|
||||
|
||||
export type RecipeMeal =
|
||||
"breakfast"
|
||||
| "lunch"
|
||||
| "dinner"
|
||||
| "dessert"
|
||||
| "snack"
|
||||
| "side"
|
||||
| "other";
|
||||
|
||||
const RECIPE_MEALS = [
|
||||
export const RECIPE_MEALS = [
|
||||
"breakfast",
|
||||
"lunch",
|
||||
"dinner",
|
||||
@ -22,15 +13,62 @@ const RECIPE_MEALS = [
|
||||
"snack",
|
||||
"side",
|
||||
"other"
|
||||
];
|
||||
] as const;
|
||||
|
||||
export type RecipeMeal = (typeof RECIPE_MEALS)[number];
|
||||
|
||||
export function isRecipeMeal(value: string): value is RecipeMeal {
|
||||
return RECIPE_MEALS.includes(value as RecipeMeal);
|
||||
}
|
||||
|
||||
export const INGREDIENT_UNITS = [
|
||||
"",
|
||||
"tsp",
|
||||
"tbsp",
|
||||
"fl oz",
|
||||
"cup",
|
||||
"ml",
|
||||
"l",
|
||||
"pt",
|
||||
"qt",
|
||||
"gal",
|
||||
"g",
|
||||
"kg",
|
||||
"oz",
|
||||
"lb",
|
||||
"piece",
|
||||
"clove",
|
||||
"slice",
|
||||
"stick",
|
||||
"bunch",
|
||||
"pinch",
|
||||
"dash",
|
||||
"splash",
|
||||
"to taste",
|
||||
] as const;
|
||||
|
||||
export type RecipeIngredientUnit = (typeof INGREDIENT_UNITS)[number];
|
||||
|
||||
export function isRecipeUnit(value: string): value is RecipeIngredientUnit {
|
||||
return INGREDIENT_UNITS.includes(value as RecipeIngredientUnit);
|
||||
}
|
||||
|
||||
export interface RecipeIngredient {
|
||||
Id: string;
|
||||
SectionId: string;
|
||||
Name: string;
|
||||
Quantity: string;
|
||||
Amount: number;
|
||||
Unit: RecipeIngredientUnit;
|
||||
}
|
||||
|
||||
export interface RecipeIngredientSection {
|
||||
Id: string;
|
||||
Name: string;
|
||||
};
|
||||
|
||||
export interface RecipeInstruction {
|
||||
Id: string;
|
||||
Content: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user