Merging in the React Refactor #56
@ -1,10 +1,10 @@
|
|||||||
import type { CreateRecipeFormToggles } from "../../pages/Create";
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
interface ValidationErrorListProps {
|
interface ValidationErrorListProps {
|
||||||
validation: CreateRecipeFormToggles;
|
validation: CreateRecipeFormEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MESSAGES: Record<keyof CreateRecipeFormToggles, string> = {
|
const MESSAGES: Record<keyof CreateRecipeFormEntries, string> = {
|
||||||
title: "Invalid title provided.",
|
title: "Invalid title provided.",
|
||||||
description: "Invalid description provided.",
|
description: "Invalid description provided.",
|
||||||
prepTime: "Invalid preparation time provided.",
|
prepTime: "Invalid preparation time provided.",
|
||||||
@ -22,7 +22,7 @@ export default function ValidationErrorList({ validation }: ValidationErrorListP
|
|||||||
{Object.entries(validation)
|
{Object.entries(validation)
|
||||||
.filter(([, isValid]) => !isValid)
|
.filter(([, isValid]) => !isValid)
|
||||||
.map(([name]) => {
|
.map(([name]) => {
|
||||||
const key = name as keyof CreateRecipeFormToggles;
|
const key = name as keyof CreateRecipeFormEntries;
|
||||||
return (
|
return (
|
||||||
<p key={name} className="text-sm text-red-500">
|
<p key={name} className="text-sm text-red-500">
|
||||||
{MESSAGES[key]}
|
{MESSAGES[key]}
|
||||||
|
|||||||
63
web/src/components/inputs/RecipeCreateFormDropdown.tsx
Normal file
63
web/src/components/inputs/RecipeCreateFormDropdown.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, type ChangeEvent, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
|
export interface RecipeCreateDropdownOption {
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipeCreateFormDropdownProps {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
required?: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
setDirty: Dispatch<SetStateAction<CreateRecipeFormEntries>>;
|
||||||
|
options: RecipeCreateDropdownOption[];
|
||||||
|
error: string;
|
||||||
|
parentClasses?: string;
|
||||||
|
classes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecipeCreateDropdownInput({ label, name, desc, required = false, valid, value, setDirty, setValue, options, error, parentClasses = "", classes }: RecipeCreateFormDropdownProps) {
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setDirty(prev => ({ ...prev, [name]: true }));
|
||||||
|
setValue(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug(`@${name}`, value);
|
||||||
|
}, [name, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${parentClasses}`}>
|
||||||
|
<label htmlFor={name} className="text-sm">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
className={`${!valid ? "border-red-500" : ""} ${classes}`}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={required}
|
||||||
|
>
|
||||||
|
{options?.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{!valid && (
|
||||||
|
<p className="text-xs text-red-500 my-1">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
63
web/src/components/inputs/RecipeCreateFormInput.tsx
Normal file
63
web/src/components/inputs/RecipeCreateFormInput.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, type ChangeEvent, type Dispatch, type InputHTMLAttributes, type SetStateAction } from "react";
|
||||||
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
|
interface RecipeCreateFormInputProps
|
||||||
|
extends Omit<
|
||||||
|
InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
"value" | "onChange" | "name" | "type" | "placeholder" | "required"
|
||||||
|
> {
|
||||||
|
label: string;
|
||||||
|
name: string; // ENSURE THE NAME MATCHES THE VALUE IN THE ENTRIES TYPE
|
||||||
|
desc: string;
|
||||||
|
placeholder: string;
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
setDirty: Dispatch<SetStateAction<CreateRecipeFormEntries>>;
|
||||||
|
error: string;
|
||||||
|
parentClasses?: string;
|
||||||
|
classes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecipeCreateFormInput({ label, name, desc, placeholder, type = "text", required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setDirty(prev => ({...prev, [name]: true}));
|
||||||
|
setValue(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug(`@${name}`, value);
|
||||||
|
}, [name, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${parentClasses}`}>
|
||||||
|
<label htmlFor={name} className="text-sm">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className={`${!valid ? "border-red-500" : ""} ${classes}`}
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
{!valid && (
|
||||||
|
<p className="text-xs text-red-500 my-1">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
70
web/src/components/inputs/RecipeCreateFormTagsInput.tsx
Normal file
70
web/src/components/inputs/RecipeCreateFormTagsInput.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useState, type ChangeEvent, type Dispatch, type FormEvent, type SetStateAction } from "react";
|
||||||
|
|
||||||
|
interface RecipeCreateFormTagsInputsProps {
|
||||||
|
tags: string[];
|
||||||
|
setTags: Dispatch<SetStateAction<string[]>>;
|
||||||
|
classes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeCreateFormTagsInputs({ tags, setTags, classes }: RecipeCreateFormTagsInputsProps) {
|
||||||
|
const [input, setInput] = useState<string>("");
|
||||||
|
|
||||||
|
const changeHandler = (e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value);
|
||||||
|
|
||||||
|
const tagCreationHandler = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// why would anyone try this lol
|
||||||
|
if (input.trim() === "") return;
|
||||||
|
|
||||||
|
// Tag already exists, clear input and exit
|
||||||
|
if (tags.includes(input.toLowerCase())) {
|
||||||
|
setInput("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInput("");
|
||||||
|
setTags(prev => [...prev, input.toLowerCase()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagDeletionHandler = (tag: string) => {
|
||||||
|
if (!tag) return;
|
||||||
|
setTags(prev => prev.filter(t => t !== tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Recipe Tags
|
||||||
|
</label>
|
||||||
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||||
|
Please provide a list of tags. <span className="italic">e.g., easy, dairy-free, gluten-free, high protein.</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={changeHandler}
|
||||||
|
name="tagInput"
|
||||||
|
maxLength={32}
|
||||||
|
enterKeyHint="done"
|
||||||
|
placeholder="e.g., Healthy"
|
||||||
|
className={classes}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="tags" id="tags" value="" />
|
||||||
|
</div>
|
||||||
|
<ul id="tag-list" className="my-2 flex gap-1 flex-wrap">
|
||||||
|
{tags?.map(tag =>
|
||||||
|
<li
|
||||||
|
className="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300"
|
||||||
|
key={tag}
|
||||||
|
>
|
||||||
|
<button tabIndex={-1} type="button" onClick={() => tagDeletionHandler(tag)}>
|
||||||
|
× {tag}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
web/src/components/inputs/RecipeCreateFormTextArea.tsx
Normal file
60
web/src/components/inputs/RecipeCreateFormTextArea.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, type ChangeEvent, type Dispatch, type InputHTMLAttributes, type SetStateAction, type TextareaHTMLAttributes } from "react";
|
||||||
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
|
interface RecipeCreateFormInputProps
|
||||||
|
extends Omit<
|
||||||
|
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
"value" | "onChange" | "name" | "type" | "placeholder" | "required"
|
||||||
|
> {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
placeholder: string;
|
||||||
|
required?: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
setDirty: Dispatch<SetStateAction<CreateRecipeFormEntries>>;
|
||||||
|
error: string;
|
||||||
|
parentClasses?: string;
|
||||||
|
classes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecipeCreateFormTextArea({ label, name, desc, placeholder, required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setDirty(prev => ({...prev, [name]: true}));
|
||||||
|
setValue(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug(`@${name}`, value);
|
||||||
|
}, [name, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${parentClasses}`}>
|
||||||
|
<label htmlFor={name} className="text-sm">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className={`${!valid ? "border-red-500" : ""} ${classes}`}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
{!valid && (
|
||||||
|
<p className="text-xs text-red-500 my-1">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
28
web/src/components/inputs/RecipeCreateFormWrapper.tsx
Normal file
28
web/src/components/inputs/RecipeCreateFormWrapper.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface RecipeCreateFormWrapperProps {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
required: boolean;
|
||||||
|
parentClasses: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeCreateFormWrapper({label, name, desc, required = false, parentClasses, children }: RecipeCreateFormWrapperProps) {
|
||||||
|
const normalized_name = name.toLowerCase().replaceAll(" ", "-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${parentClasses}`}>
|
||||||
|
<label htmlFor={normalized_name} className="text-sm">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs py-1 text-gray-700">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import Banner from "../components/Banner";
|
import Banner from "../components/Banner";
|
||||||
import { isRecipeMeal, type RecipeIngredient, type RecipeIngredientSection, type RecipeIngredientUnit, type RecipeInstruction } from "../types/recipe";
|
import { isRecipeMeal, type RecipeIngredient, type RecipeIngredientSection, type RecipeIngredientUnit, type RecipeInstruction } from "../types/recipe";
|
||||||
import InstructionList from "../components/forms/InstructionList";
|
import InstructionList from "../components/forms/InstructionList";
|
||||||
@ -6,25 +6,14 @@ import ValidationErrorList from "../components/forms/ValidationErrorList";
|
|||||||
import IngredientSection from "../components/forms/IngredientSection";
|
import IngredientSection from "../components/forms/IngredientSection";
|
||||||
import { Reorder } from "motion/react";
|
import { Reorder } from "motion/react";
|
||||||
import IngredientList from "../components/forms/IngredientList";
|
import IngredientList from "../components/forms/IngredientList";
|
||||||
|
import RecipeCreateFormInput from "../components/inputs/RecipeCreateFormInput";
|
||||||
|
import RecipeCreateDropdownInput, { type RecipeCreateDropdownOption } from "../components/inputs/RecipeCreateFormDropdown";
|
||||||
|
import RecipeCreateFormTextArea from "../components/inputs/RecipeCreateFormTextArea";
|
||||||
|
import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrapper";
|
||||||
|
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
||||||
|
|
||||||
// TODO: Move this
|
// TODO: Move this
|
||||||
interface CreateRecipeForm {
|
export interface CreateRecipeFormEntries {
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
tagInput: string; // current tag being typed
|
|
||||||
tags: string[]; // all tags
|
|
||||||
prepTime: number | "";
|
|
||||||
cookTime: number | "";
|
|
||||||
servingSize: number | "";
|
|
||||||
category: string;
|
|
||||||
difficulty: string; // We use this as a number...
|
|
||||||
ingredients: { name: string; quantity: string }[];
|
|
||||||
// Instructions are stored elsewhere
|
|
||||||
image: File | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Move this
|
|
||||||
export interface CreateRecipeFormToggles {
|
|
||||||
title: boolean;
|
title: boolean;
|
||||||
description: boolean;
|
description: boolean;
|
||||||
prepTime: boolean;
|
prepTime: boolean;
|
||||||
@ -42,36 +31,54 @@ export interface CreateRecipeFormToggles {
|
|||||||
*/
|
*/
|
||||||
const INPUT_CLASSES = "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";
|
const INPUT_CLASSES = "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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options passed to the category dropdown.
|
||||||
|
*/
|
||||||
|
const CATEGORY_OPTIONS: RecipeCreateDropdownOption[] = [
|
||||||
|
{ value: "", name: "Select a category" },
|
||||||
|
{ value: "breakfast", name: "Breakfast" },
|
||||||
|
{ value: "lunch", name: "Lunch" },
|
||||||
|
{ value: "dinner", name: "Dinner" },
|
||||||
|
{ value: "dessert", name: "Dessert" },
|
||||||
|
{ value: "snack", name: "Snack" },
|
||||||
|
{ value: "side", name: "Side" },
|
||||||
|
{ value: "other", name: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options passed to the difficulty dropdown.
|
||||||
|
*/
|
||||||
|
const DIFFICULTY_OPTIONS: RecipeCreateDropdownOption[] = [
|
||||||
|
{ value: "", name: "Select a difficulty" },
|
||||||
|
{ value: "1", name: "Beginner" },
|
||||||
|
{ value: "2", name: "Easy" },
|
||||||
|
{ value: "3", name: "Intermediate" },
|
||||||
|
{ value: "4", name: "Challenging" },
|
||||||
|
{ value: "5", name: "Extreme" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Create() {
|
export default function Create() {
|
||||||
// FORM STATE
|
// Inputs
|
||||||
const [inputs, setInputs] = useState<CreateRecipeForm>({
|
const [title, setTitle] = useState<string>("");
|
||||||
title: "",
|
const [description, setDescription] = useState<string>("");
|
||||||
description: "",
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
tagInput: "",
|
const [prepTime, setPrepTime] = useState<string>("");
|
||||||
tags: [],
|
const [cookTime, setCookTime] = useState<string>("");
|
||||||
prepTime: "",
|
const [servingSize, setServingSize] = useState<string>("");
|
||||||
cookTime: "",
|
const [category, setCategory] = useState<string>("");
|
||||||
servingSize: "",
|
const [difficulty, setDifficulty] = useState<string>("");
|
||||||
category: "",
|
|
||||||
difficulty: "",
|
|
||||||
ingredients: [{ name: "", quantity: "" }],
|
|
||||||
image: null,
|
|
||||||
});
|
|
||||||
// Store complex values elsewhere
|
|
||||||
const [instructions, setInstructions] = useState<RecipeInstruction[]>([
|
const [instructions, setInstructions] = useState<RecipeInstruction[]>([
|
||||||
{ Id: crypto.randomUUID(), Content: "" }
|
{ Id: crypto.randomUUID(), Content: "" }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [sections, setSections] = useState<RecipeIngredientSection[]>([
|
const [sections, setSections] = useState<RecipeIngredientSection[]>([
|
||||||
{ Id: "initial-section", Name: "Unnamed group" },
|
{ Id: "initial-section", Name: "Unnamed group" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [ingredients, setIngredients] = useState<RecipeIngredient[]>([
|
const [ingredients, setIngredients] = useState<RecipeIngredient[]>([
|
||||||
{ Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" },
|
{ Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// VALIDATION STATE
|
// Validation State
|
||||||
const [validation, setValidation] = useState<CreateRecipeFormToggles>({
|
const [validation, setValidation] = useState<CreateRecipeFormEntries>({
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
prepTime: true,
|
prepTime: true,
|
||||||
@ -82,7 +89,10 @@ export default function Create() {
|
|||||||
ingredients: true,
|
ingredients: true,
|
||||||
instructions: true,
|
instructions: true,
|
||||||
});
|
});
|
||||||
const [dirty, setDirty] = useState<CreateRecipeFormToggles>({
|
const [isFormValid, setIsFormValid] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Dirty State
|
||||||
|
const [dirty, setDirty] = useState<CreateRecipeFormEntries>({
|
||||||
title: false,
|
title: false,
|
||||||
description: false,
|
description: false,
|
||||||
prepTime: false,
|
prepTime: false,
|
||||||
@ -93,74 +103,31 @@ export default function Create() {
|
|||||||
ingredients: true, // This can be ignored since they're self contained
|
ingredients: true, // This can be ignored since they're self contained
|
||||||
instructions: true, // This we can ignore since they're self contained
|
instructions: true, // This we can ignore since they're self contained
|
||||||
});
|
});
|
||||||
const [isFormValid, setIsFormValid] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Validate the dirty inputs, one at a time
|
// Validate the dirty inputs, one at a time
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const state = { ...validation };
|
const state = { ...validation };
|
||||||
state.title = dirty.title ? (inputs.title.length >= 1 && inputs.title.length <= 128) : true;
|
state.title = dirty.title ? (title.length >= 1 && title.length <= 128) : true;
|
||||||
state.description = dirty.description ? (inputs.description.length >= 1 && inputs.description.length <= 1000) : true;
|
state.description = dirty.description ? (description.length >= 1 && description.length <= 1000) : true;
|
||||||
|
|
||||||
state.prepTime = dirty.prepTime ? (inputs.prepTime !== "" && Number(inputs.prepTime) >= 0 && Number(inputs.prepTime) <= 120) : true;
|
state.prepTime = dirty.prepTime ? (prepTime !== "" && Number(prepTime) >= 0 && Number(prepTime) <= 300) : true;
|
||||||
state.cookTime = dirty.cookTime ? (inputs.cookTime !== "" && Number(inputs.cookTime) >= 0 && Number(inputs.cookTime) <= 120) : true;
|
state.cookTime = dirty.cookTime ? (cookTime !== "" && Number(cookTime) >= 0 && Number(cookTime) <= 300) : true;
|
||||||
state.servingSize = dirty.servingSize ? (inputs.servingSize !== "" && Number(inputs.servingSize) >= 1 && Number(inputs.servingSize) <= 16) : true;
|
state.servingSize = dirty.servingSize ? (servingSize !== "" && Number(servingSize) >= 1 && Number(servingSize) <= 16) : true;
|
||||||
|
|
||||||
state.category = dirty.category ? (inputs.category !== "" && isRecipeMeal(inputs.category)) : true;
|
state.category = dirty.category ? (category !== "" && isRecipeMeal(category)) : true;
|
||||||
state.difficulty = dirty.difficulty ? (inputs.difficulty !== "" && Number(inputs.difficulty) >= 1 && Number(inputs.difficulty) <= 5) : true;
|
state.difficulty = dirty.difficulty ? (difficulty !== "" && Number(difficulty) >= 1 && Number(difficulty) <= 5) : true;
|
||||||
// state.instructions = instructions?.filter(x => x.content === "").length === 0; // All of them are not empty
|
|
||||||
|
// TODO: How do I validate the instructions and ingredients
|
||||||
|
|
||||||
setValidation(state);
|
setValidation(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HANDLERS
|
// Instruction handlers
|
||||||
const changeHandler = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setInputs(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
setDirty(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: true,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const tagCreationHandler = (e: FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// why would anyone try this lol
|
|
||||||
if (inputs.tagInput.trim() === "") return;
|
|
||||||
|
|
||||||
// Tag already exists, clear input and exit
|
|
||||||
if (inputs.tags.includes(inputs.tagInput.toLowerCase())) {
|
|
||||||
setInputs(prev => ({
|
|
||||||
...prev,
|
|
||||||
tagInput: "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputs(prev => ({
|
|
||||||
...prev,
|
|
||||||
tags: [...prev.tags, inputs.tagInput.toLowerCase()],
|
|
||||||
tagInput: "",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagDeletionHandler = (tag: string) => {
|
|
||||||
if (!tag) return;
|
|
||||||
|
|
||||||
setInputs(prev => ({
|
|
||||||
...prev,
|
|
||||||
tags: prev.tags.filter(t => t !== tag)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const addInstructionHandler = () => {
|
const addInstructionHandler = () => {
|
||||||
setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
|
setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ingredient Handlers
|
||||||
const sectionChangeHandler = (id: string, name: string) => {
|
const sectionChangeHandler = (id: string, name: string) => {
|
||||||
setSections(prev =>
|
setSections(prev =>
|
||||||
prev.map(section =>
|
prev.map(section =>
|
||||||
@ -230,7 +197,7 @@ export default function Create() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Execute validation every time inputs change
|
// Execute validation every time inputs change
|
||||||
validate();
|
validate();
|
||||||
}, [inputs, instructions, ingredients]);
|
}, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The form is only valid when every item has been touched, and every item is valid!
|
// The form is only valid when every item has been touched, and every item is valid!
|
||||||
@ -239,6 +206,14 @@ export default function Create() {
|
|||||||
setIsFormValid(allValid && allDirty);
|
setIsFormValid(allValid && allDirty);
|
||||||
}, [validation, dirty]);
|
}, [validation, dirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug("@validation", validation);
|
||||||
|
}, [validation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug("@dirty", dirty);
|
||||||
|
}, [dirty]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Banner content="Create Your Masterpiece" />
|
<Banner content="Create Your Masterpiece" />
|
||||||
@ -254,235 +229,145 @@ export default function Create() {
|
|||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
{/* Title Input */}
|
{/* Title Input */}
|
||||||
<div className="flex flex-col">
|
<RecipeCreateFormInput
|
||||||
<label htmlFor="title" className="text-sm">
|
label="Recipe Title"
|
||||||
Recipe Title
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide a unique title for your recipe. This is the most important part!
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className={`${!validation.title ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
|
||||||
type="text"
|
|
||||||
name="title"
|
name="title"
|
||||||
value={inputs.title}
|
desc="Please provide a unique title for your recipe. This is the most important part!"
|
||||||
onChange={changeHandler}
|
|
||||||
required
|
|
||||||
maxLength={128}
|
|
||||||
minLength={1}
|
|
||||||
placeholder="e.g., Classic Chicken Curry"
|
placeholder="e.g., Classic Chicken Curry"
|
||||||
|
required
|
||||||
|
valid={validation.title}
|
||||||
|
value={title}
|
||||||
|
setValue={setTitle}
|
||||||
|
setDirty={setDirty}
|
||||||
|
maxLength={128}
|
||||||
|
error="Please enter a title. Between 1-128 characters."
|
||||||
|
classes={INPUT_CLASSES}
|
||||||
/>
|
/>
|
||||||
{!validation.title && (
|
|
||||||
<p className="text-xs text-red-500 my-1">Please enter a title. Between 1-128 characters.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description Input */}
|
{/* Description Input */}
|
||||||
<div className="flex flex-col my-4">
|
<RecipeCreateFormTextArea
|
||||||
<label htmlFor="description" className="text-sm">
|
label="Description"
|
||||||
Description
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide a description for your recipe. This can be short and sweet or long and detailed!
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
className={`${!validation.description ? "border-red-500" : ""} ${INPUT_CLASSES} min-h-32`}
|
|
||||||
name="description"
|
name="description"
|
||||||
value={inputs.description}
|
desc="Please provide a description for your recipe. This can be short and sweet or long and detailed!"
|
||||||
onChange={changeHandler}
|
placeholder="A brief description of your delicious recipe..."
|
||||||
rows={4}
|
|
||||||
required
|
required
|
||||||
|
valid={validation.description}
|
||||||
|
value={description}
|
||||||
|
setValue={setDescription}
|
||||||
|
setDirty={setDirty}
|
||||||
|
error="Please enter a description. Between 1-1000 characters."
|
||||||
|
rows={4}
|
||||||
maxLength={1024}
|
maxLength={1024}
|
||||||
minLength={1}
|
minLength={1}
|
||||||
placeholder="A brief description of your delicious recipe..."
|
parentClasses="my-4"
|
||||||
></textarea>
|
classes={INPUT_CLASSES}
|
||||||
{!validation.description && (
|
/>
|
||||||
<p className="text-xs text-red-500 my-1">
|
|
||||||
Please enter a description. Between 1-1000 characters.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag Input */}
|
{/* Tag Input */}
|
||||||
<form onSubmit={tagCreationHandler} className="my-4 flex flex-col gap-x-2">
|
<RecipeCreateFormTagsInputs
|
||||||
<div className="flex flex-col flex-grow">
|
tags={tags}
|
||||||
<label htmlFor="tags" className="text-sm">
|
setTags={setTags}
|
||||||
Recipe Tags
|
classes={INPUT_CLASSES}
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide a list of tags. <span className="italic">e.g., easy, dairy-free, gluten-free, high protein.</span>
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inputs.tagInput}
|
|
||||||
onChange={changeHandler}
|
|
||||||
name="tagInput"
|
|
||||||
maxLength={32}
|
|
||||||
enterKeyHint="done"
|
|
||||||
placeholder="e.g., Healthy"
|
|
||||||
className="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"
|
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name="tags" id="tags" value="" />
|
|
||||||
</div>
|
|
||||||
<ul id="tag-list" className="my-2 flex gap-1 flex-wrap">
|
|
||||||
{inputs.tags.map(tag =>
|
|
||||||
<li
|
|
||||||
className="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300"
|
|
||||||
key={tag}
|
|
||||||
>
|
|
||||||
<button tabIndex={-1} type="button" onClick={() => tagDeletionHandler(tag)}>
|
|
||||||
× {tag}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Time Input */}
|
{/* Time Input */}
|
||||||
<div className="my-4 flex gap-x-2">
|
<div className="my-4 flex gap-x-2">
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
<RecipeCreateFormInput
|
||||||
<label htmlFor="prepTime" className="text-sm">
|
label="Prep Time"
|
||||||
Prep Time
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide the estimated prep time (minutes).
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className={`${!validation.prepTime ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
|
||||||
type="number"
|
|
||||||
name="prepTime"
|
name="prepTime"
|
||||||
value={inputs.prepTime}
|
type="number"
|
||||||
onChange={changeHandler}
|
desc="Please provide the estimated prep time (minutes)."
|
||||||
required
|
|
||||||
min="0"
|
|
||||||
max="120"
|
|
||||||
placeholder="e.g., 20"
|
placeholder="e.g., 20"
|
||||||
/>
|
|
||||||
{!validation.prepTime && (
|
|
||||||
<p className="text-xs text-red-500 my-1"> Please enter a time (minutes). </p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
|
||||||
<label htmlFor="cookTime" className="text-sm">
|
|
||||||
Cook Time
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide the estimated cook time (minutes).
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className={`${!validation.cookTime ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
|
||||||
type="number"
|
|
||||||
name="cookTime"
|
|
||||||
value={inputs.cookTime}
|
|
||||||
onChange={changeHandler}
|
|
||||||
required
|
required
|
||||||
|
valid={validation.prepTime}
|
||||||
|
value={prepTime}
|
||||||
|
setValue={setPrepTime}
|
||||||
|
setDirty={setDirty}
|
||||||
|
error="Please enter a time (minutes)."
|
||||||
|
parentClasses="flex-grow w-1/3"
|
||||||
min="0"
|
min="0"
|
||||||
max="120"
|
max="300"
|
||||||
placeholder="e.g., 45"
|
classes={INPUT_CLASSES}
|
||||||
/>
|
/>
|
||||||
{!validation.cookTime && (
|
|
||||||
<p className="text-xs text-red-500 my-1"> Please enter a time (minutes). </p>
|
<RecipeCreateFormInput
|
||||||
)}
|
label="Cook Time"
|
||||||
</div>
|
name="cookTime"
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
|
||||||
<label htmlFor="servingSize" className="text-sm">
|
|
||||||
Serving Size
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide the estimated serving size.
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className={`${!validation.servingSize ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
|
||||||
type="number"
|
type="number"
|
||||||
name="servingSize"
|
desc="Please provide the estimated cook time (minutes)."
|
||||||
value={inputs.servingSize}
|
placeholder="e.g., 45"
|
||||||
onChange={changeHandler}
|
|
||||||
max="16"
|
|
||||||
min="1"
|
|
||||||
required
|
required
|
||||||
placeholder="e.g., 4"
|
valid={validation.cookTime}
|
||||||
|
value={cookTime}
|
||||||
|
setValue={setCookTime}
|
||||||
|
setDirty={setDirty}
|
||||||
|
error="Please enter a time (minutes)."
|
||||||
|
parentClasses="flex-grow w-1/3"
|
||||||
|
min="0"
|
||||||
|
max="300"
|
||||||
|
classes={INPUT_CLASSES}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RecipeCreateFormInput
|
||||||
|
label="Serving Size"
|
||||||
|
name="servingSize"
|
||||||
|
type="number"
|
||||||
|
desc="Please provide the estimated serving size."
|
||||||
|
placeholder="e.g., 4"
|
||||||
|
required
|
||||||
|
valid={validation.servingSize}
|
||||||
|
value={servingSize}
|
||||||
|
setValue={setServingSize}
|
||||||
|
setDirty={setDirty}
|
||||||
|
error="Please enter a serving size."
|
||||||
|
parentClasses="flex-grow w-1/3"
|
||||||
|
min="1"
|
||||||
|
max="16"
|
||||||
|
classes={INPUT_CLASSES}
|
||||||
/>
|
/>
|
||||||
{!validation.servingSize && (
|
|
||||||
<p className="text-xs text-red-500 my-1"> Please enter a serving size. </p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dropdown Inputs */}
|
{/* Dropdown Inputs */}
|
||||||
<div className="my-4 flex gap-x-2">
|
<div className="my-4 flex gap-x-2">
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
<RecipeCreateDropdownInput
|
||||||
<label htmlFor="category" className="text-sm">
|
label="Category"
|
||||||
Category
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide the meal category.
|
|
||||||
</p>
|
|
||||||
<select
|
|
||||||
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
|
||||||
name="category"
|
name="category"
|
||||||
value={inputs.category}
|
desc="Please provide the meal category."
|
||||||
onChange={changeHandler}
|
|
||||||
required
|
required
|
||||||
>
|
valid={validation.category}
|
||||||
<option value="">Select a category</option>
|
value={category}
|
||||||
<option value="breakfast">Breakfast</option>
|
setValue={setCategory}
|
||||||
<option value="lunch">Lunch</option>
|
setDirty={setDirty}
|
||||||
<option value="dinner">Dinner</option>
|
options={CATEGORY_OPTIONS}
|
||||||
<option value="dessert">Dessert</option>
|
error="Please select a category."
|
||||||
<option value="snack">Snack</option>
|
parentClasses="flex-grow w-1/3"
|
||||||
<option value="side">Side</option>
|
classes={INPUT_CLASSES}
|
||||||
<option value="other">Other</option>
|
/>
|
||||||
</select>
|
|
||||||
{!validation.category && (
|
<RecipeCreateDropdownInput
|
||||||
<p className="text-xs text-red-500 my-1"> Please select a category. </p>
|
label="Difficulty"
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
|
||||||
<label htmlFor="difficulty" className="text-sm">
|
|
||||||
Difficulty
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide a baseline difficulty.
|
|
||||||
</p>
|
|
||||||
<select
|
|
||||||
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
|
||||||
name="difficulty"
|
name="difficulty"
|
||||||
value={inputs.difficulty}
|
desc="Please provide a baseline difficulty."
|
||||||
onChange={changeHandler}
|
|
||||||
required
|
required
|
||||||
>
|
valid={validation.difficulty}
|
||||||
<option value="">Select a difficulty</option>
|
value={difficulty}
|
||||||
<option value="1">Beginner</option>
|
setValue={setDifficulty}
|
||||||
<option value="2">Easy</option>
|
setDirty={setDirty}
|
||||||
<option value="3">Intermediate</option>
|
options={DIFFICULTY_OPTIONS}
|
||||||
<option value="4">Challenging</option>
|
error="Please select a difficulty."
|
||||||
<option value="5">Extreme</option>
|
parentClasses="flex-grow w-1/3"
|
||||||
</select>
|
classes={INPUT_CLASSES}
|
||||||
{!validation.difficulty && (
|
/>
|
||||||
<p className="text-xs text-red-500 my-1"> Please select a difficulty. </p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO: Ingredient Inputs */}
|
{/* TODO: Ingredient Inputs */}
|
||||||
<div className="flex flex-col my-4">
|
<RecipeCreateFormWrapper
|
||||||
<label htmlFor="ingredients" className="text-sm">
|
label="Ingredients"
|
||||||
Ingredients
|
name="ingredients"
|
||||||
<span className="text-red-500">*</span>
|
desc="Please provide a list of ingredients and their quantities."
|
||||||
</label>
|
required
|
||||||
<p className="text-xs py-1 text-gray-700">
|
parentClasses="my-4"
|
||||||
Please provide a list of ingredients and their quantities. Ingredients can be added into
|
>
|
||||||
groups. If only a single group exists, the group will be ignored when the recipe is created
|
|
||||||
and the ingredients will appear as a single list.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Reorder.Group
|
<Reorder.Group
|
||||||
axis="y"
|
axis="y"
|
||||||
values={sections}
|
values={sections}
|
||||||
@ -497,7 +382,6 @@ export default function Create() {
|
|||||||
removeIngredientSectionHandler={removeIngredientSectionHandler}
|
removeIngredientSectionHandler={removeIngredientSectionHandler}
|
||||||
allowDelete={sections.length > 1}
|
allowDelete={sections.length > 1}
|
||||||
>
|
>
|
||||||
|
|
||||||
<IngredientList
|
<IngredientList
|
||||||
classes={INPUT_CLASSES}
|
classes={INPUT_CLASSES}
|
||||||
section={section}
|
section={section}
|
||||||
@ -521,28 +405,29 @@ export default function Create() {
|
|||||||
</IngredientSection>
|
</IngredientSection>
|
||||||
))}
|
))}
|
||||||
</Reorder.Group>
|
</Reorder.Group>
|
||||||
|
</RecipeCreateFormWrapper>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Instruction Inputs */}
|
{/* Instruction Inputs */}
|
||||||
<div className="flex flex-col my-4">
|
<RecipeCreateFormWrapper
|
||||||
<label htmlFor="instructions" className="text-sm">
|
label="Instructions"
|
||||||
Instructions
|
name="instructions"
|
||||||
<span className="text-red-500">*</span>
|
desc="Please provide a list of instructions. You do not need to include step number, they will be added automatically!"
|
||||||
</label>
|
required
|
||||||
<p className="text-xs py-1 text-gray-700">
|
parentClasses="my-4"
|
||||||
Please provide a list of instructions. You do not need to include step number, they will be added automatically!
|
>
|
||||||
</p>
|
<>
|
||||||
|
<InstructionList
|
||||||
<InstructionList instructions={instructions} setInstructions={setInstructions} />
|
instructions={instructions}
|
||||||
|
setInstructions={setInstructions}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={addInstructionHandler}
|
onClick={addInstructionHandler}
|
||||||
className="w-fit px-3 py-1.5 text-blue-800 font-semibold text-xs md:text-sm rounded-lg hover:bg-gray-100 duration-300 cursor-pointer"
|
className="w-fit px-3 py-1.5 text-blue-800 font-semibold text-xs md:text-sm rounded-lg hover:bg-gray-100 duration-300 cursor-pointer"
|
||||||
>
|
>
|
||||||
Add Instruction Step
|
Add Instruction Step
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
|
</RecipeCreateFormWrapper>
|
||||||
|
|
||||||
{/* TODO: Images Input */}
|
{/* TODO: Images Input */}
|
||||||
<div className="flex flex-col my-4">
|
<div className="flex flex-col my-4">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user