(WIP): Moving to desktop

This commit is contained in:
Hayden Hargreaves 2025-12-10 15:01:53 -07:00
parent d18710e1fc
commit d170429752
8 changed files with 260 additions and 83 deletions

View 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"
/>
);
}

View 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>
</>
);
}

View 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>
);
}

View File

@ -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>

View File

@ -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}

View 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>
);
}

View File

@ -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}

View File

@ -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 {