(FEAT): Worked on the instruction list!

This is great! Looking so much better than it did before! And it works
the way I wanted it to! Yayy!!! The reorder stuff is awesome too!
This commit is contained in:
Hayden Hargreaves 2025-12-02 22:23:05 -07:00
parent 1acc3792c5
commit 728b7eb28c
6 changed files with 289 additions and 32 deletions

115
web/package-lock.json generated
View File

@ -8,10 +8,12 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.2",
"eslint-plugin-react-dom": "^2.2.4",
"eslint-plugin-react-x": "^2.2.4",
"motion": "^12.23.25",
"react": "^19.1.1",
"react-cookie": "^8.0.1",
"react-dom": "^19.1.1",
@ -33,6 +35,45 @@
"vite": "^7.1.7"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
@ -2676,6 +2717,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.23.25",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.25.tgz",
"integrity": "sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3332,6 +3400,47 @@
"node": "*"
}
},
"node_modules/motion": {
"version": "12.23.25",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.25.tgz",
"integrity": "sha512-Fk5Y1kcgxYiTYOUjmwfXQAP7tP+iGqw/on1UID9WEL/6KpzxPr9jY2169OsjgZvXJdpraKXy0orkjaCVIl5fgQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.23.25",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3903,6 +4012,12 @@
"integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -10,10 +10,12 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.2",
"eslint-plugin-react-dom": "^2.2.4",
"eslint-plugin-react-x": "^2.2.4",
"motion": "^12.23.25",
"react": "^19.1.1",
"react-cookie": "^8.0.1",
"react-dom": "^19.1.1",

View File

@ -0,0 +1,80 @@
import { useEffect, useState, type ChangeEvent } from "react";
import type { Instruction } from "../../pages/Create";
import { Reorder, useDragControls } from "motion/react";
import DragIconSmall from "../icons/DragIconSmall";
interface InstructionElementProps {
instruction: Instruction;
index: number;
allowDelete: boolean;
onChange: (id: string, value: string) => void;
onDelete: (id: string) => void;
}
export default function InstructionElement({ instruction, index, allowDelete, onChange, onDelete }: InstructionElementProps) {
const controls = useDragControls();
const [valid, setValid] = useState<boolean>(true);
const [dirty, setDirty] = useState<boolean>(false);
// HANDLERS
const changeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
// No need to set many times
if (!dirty) setDirty(true);
onChange(instruction.id, e.target.value);
}
// EFFECTS
useEffect(() => {
if (dirty)
setValid(instruction.content !== "");
}, [dirty, instruction]);
return (
<Reorder.Item
value={instruction}
dragListener={false}
dragControls={controls}
className="flex items-center"
>
<div className="flex flex-grow items-center select-none">
<h2 className="text-lg md:text-xl mr-4 text-gray-500">{index + 1}.</h2>
<div className="flex flex-col flex-grow">
<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}
onChange={changeHandler}
rows={3}
required
minLength={1}
placeholder="Describe this step..."
/>
{!valid && (
<p className="text-xs text-red-500 my-1">
Please enter an instruction (blank entries are not allowed).
</p>
)}
</div>
</div>
<div className="flex flex-col items-center">
<div className="p-2 pr-0 cursor-grab" onPointerDown={e => controls.start(e)}>
<DragIconSmall />
</div>
<button
tabIndex={-1}
disabled={!allowDelete}
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>
</button>
</div>
</Reorder.Item>
);
}

View File

@ -0,0 +1,45 @@
import { Reorder } from "motion/react";
import type { Instruction } from "../../pages/Create";
import InstructionElement from "./InstructionElement";
interface InstructionFormProps {
instructions: Instruction[];
setInstructions: React.Dispatch<React.SetStateAction<Instruction[]>>
}
export default function InstructionForm({ instructions, setInstructions }: InstructionFormProps) {
const handleChange = (id: string, value: string) => {
setInstructions(prev =>
prev.map(instr =>
instr.id === id ? { ...instr, content: value } : instr
)
);
};
const handleDelete = (id: string) => {
setInstructions(prev =>
prev.filter(instr => instr.id !== id)
);
}
return (
<Reorder.Group
axis="y"
values={instructions}
onReorder={setInstructions}
className="flex flex-col gap-2 my-2"
>
{instructions.map((instruction, i) => (
<InstructionElement
key={instruction.id}
index={i}
instruction={instruction}
allowDelete={instructions.length > 1}
onChange={handleChange}
onDelete={handleDelete}
/>
))}
</Reorder.Group>
);
}

View File

@ -0,0 +1,7 @@
export default function DragIconSmall() {
return (
<svg className="text-gray-500 size-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6H20M4 12H20M4 18H20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import Banner from "../components/Banner";
import { isRecipeMeal } from "../types/recipe";
import InstructionForm from "../components/forms/InstructionForm";
interface CreateRecipeForm {
title: string;
@ -13,7 +14,7 @@ interface CreateRecipeForm {
category: string;
difficulty: string; // We use this as a number...
ingredients: { name: string; quantity: string }[];
instructions: string[];
// Instructions are stored elsewhere
image: File | null;
};
@ -30,6 +31,11 @@ interface CreateRecipeFormToggles {
// TODO: Image
}
export interface Instruction {
id: string;
content: string;
}
/**
* Classes which are applied to all of the input elements.
*/
@ -48,9 +54,10 @@ export default function Create() {
category: "",
difficulty: "",
ingredients: [{ name: "", quantity: "" }],
instructions: [""],
image: null,
});
// Store complex values elsewhere
const [instructions, setInstructions] = useState<Instruction[]>([{ id: crypto.randomUUID(), content: "" }, { id: crypto.randomUUID(), content: "" }]);
// VALIDATION STATE
const [validation, setValidation] = useState<CreateRecipeFormToggles>({
@ -72,8 +79,8 @@ export default function Create() {
servingSize: false,
category: false,
difficulty: false,
ingredients: false,
instructions: false,
ingredients: true, // This can be ignored since they're self contained
instructions: true, // This we can ignore since they're self contained
});
const [isFormValid, setIsFormValid] = useState<boolean>(false);
@ -89,6 +96,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
setValidation(state);
}
@ -104,7 +112,7 @@ export default function Create() {
return true;
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const changeHandler = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setInputs(prev => ({
...prev,
@ -116,12 +124,25 @@ export default function Create() {
}));
};
const addInstructionHandler = () => {
setInstructions([...instructions, { id: crypto.randomUUID(), content: "" }]);
}
// EFFECTS
useEffect(() => {
// Execute validation every time inputs change
validate();
console.log("@inputs", inputs);
}, [inputs]);
// console.log("@inputs", inputs);
}, [inputs, instructions]);
// useEffect(() => {
// console.log("@validation", validation);
// }, [validation]);
useEffect(() => {
console.log("@instructions", instructions);
}, [instructions]);
useEffect(() => {
// The form is only valid when every item has been touched, and every item is valid!
@ -159,7 +180,7 @@ export default function Create() {
type="text"
name="title"
value={inputs.title}
onChange={handleChange}
onChange={changeHandler}
required
maxLength={128}
minLength={1}
@ -183,7 +204,7 @@ export default function Create() {
className={`${!validation.description ? "border-red-500" : ""} ${INPUT_CLASSES} min-h-32`}
name="description"
value={inputs.description}
onChange={handleChange}
onChange={changeHandler}
rows={4}
required
maxLength={1024}
@ -237,7 +258,7 @@ export default function Create() {
type="number"
name="prepTime"
value={inputs.prepTime}
onChange={handleChange}
onChange={changeHandler}
required
min="0"
max="120"
@ -261,7 +282,7 @@ export default function Create() {
type="number"
name="cookTime"
value={inputs.cookTime}
onChange={handleChange}
onChange={changeHandler}
required
min="0"
max="120"
@ -285,7 +306,7 @@ export default function Create() {
type="number"
name="servingSize"
value={inputs.servingSize}
onChange={handleChange}
onChange={changeHandler}
max="16"
min="1"
required
@ -311,7 +332,7 @@ export default function Create() {
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
name="category"
value={inputs.category}
onChange={handleChange}
onChange={changeHandler}
required
>
<option value="">Select a category</option>
@ -339,7 +360,7 @@ export default function Create() {
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
name="difficulty"
value={inputs.difficulty}
onChange={handleChange}
onChange={changeHandler}
required
>
<option value="">Select a difficulty</option>
@ -417,25 +438,12 @@ export default function Create() {
<p className="text-xs py-1 text-gray-700">
Please provide a list of instructions. You do not need to include step number, they will be added automatically!
</p>
<div id="instruction-list" className="flex flex-col">
<textarea
className="peer 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 resize-none shadow-sm invalid:border-red-500
valid:my-2 invalid:mt-2"
id="instructions"
name="instructions"
rows={3}
required
minLength={1}
placeholder="Step 1: Describe this step..."
></textarea>
<p className="text-xs text-red-500 my-1">
Please enter at least one step.
</p>
</div>
<InstructionForm instructions={instructions} setInstructions={setInstructions} />
<button
type="button"
className="text-base md:text-lg text-white bg-blue-500 w-fit px-5 py-2 rounded-lg cursor-pointer"
onClick={addInstructionHandler}
className="text-sm md:text-base text-white bg-blue-500 w-full md:w-fit px-8 py-2.5 rounded-lg cursor-pointer"
>
Add Instruction Step
</button>