502 lines
14 KiB
TypeScript

import {
PaymentType,
SkillLevel,
Skill,
type PlatformLink,
Platform,
} from "@prisma/client";
import {
PencilSquareIcon,
XCircleIcon as XCircleSolid,
} from "@heroicons/react/24/solid";
import {
ChevronDownIcon,
TrashIcon,
XCircleIcon as XCircleOutline,
} from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/20/solid";
import {
DropdownSelector,
MultiSelectorMany,
MultiSelectorOption,
SelectedManyContext,
SimpleSelectorManyOption,
} from "../../forms/selectors";
import { GenericInput, InfoInputLine } from "~/components/forms/textInput";
import { PriceIcon } from "~/prices/Icons";
import {
type Dispatch,
type SetStateAction,
useState,
useEffect,
type ChangeEvent,
} from "react";
import {
type UseFormReturn,
FormProvider,
useFormContext,
useForm,
} from "react-hook-form";
import Modal from "react-modal";
import { type RouterInputs } from "~/utils/api";
import { PlatformLinkButton } from "~/pages/resources/[id]";
import { ResourcePhoto } from "~/components/ResourcePhoto";
// Required for accessibility
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
Modal.setAppElement("#__next");
export type ResourceUpdateInput = RouterInputs["auditoryResource"]["update"];
/**
* Renders the image selector for resource form.
*
* File needs to be path relative to resource_logos/
*/
const SelectImageInput = () => {
const { setValue, setError, watch } = useFormContext<ResourceUpdateInput>();
const name = watch("name");
const photo = watch("photo");
const icon = watch("icon");
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files || !event.target.files[0]) {
return;
}
const file = event.target.files[0];
const reader = new FileReader();
reader.onloadend = () => {
if (!reader.result || !(reader.result instanceof ArrayBuffer)) {
setError("photo.data", { message: "Failed uploading the photo data." });
return;
}
setValue("photo", {
name: file.name,
data: Buffer.from(reader.result),
});
};
reader.readAsArrayBuffer(file);
};
return (
<>
<label
htmlFor="resource-image-file"
className="bg-whit group relative cursor-pointer overflow-hidden rounded-xl border border-neutral-400 drop-shadow-lg"
>
<ResourcePhoto
name={name ?? "unknown resource logo"}
photo={photo ?? null}
src={icon}
/>
<div className="absolute bottom-0 left-0 right-0 top-0 hidden place-items-center group-hover:grid group-hover:bg-white/70">
<PencilSquareIcon className="w-16 text-black/50" />
</div>
</label>
<input
onChange={onChange}
accept="image/*"
id="resource-image-file"
type="file"
className="hidden"
></input>
</>
);
};
const LinkModal = ({
isOpen,
setOpen,
}: {
isOpen: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
}) => {
const { setValue, getValues } = useFormContext<ResourceUpdateInput>();
const {
register,
handleSubmit,
setValue: setLocalFormValue,
} = useForm<PlatformLink>();
const platformTypeOptions = [
{
label: "Website",
value: Platform.WEBSITE,
},
{
label: "iOS App",
value: Platform.APP_IOS,
},
{
label: "Android App",
value: Platform.APP_ANDROID,
},
{
label: "PDF Document",
value: Platform.PDF,
},
];
const onSubmit = (data: PlatformLink) => {
const values = getValues().platform_links ?? [];
values.push(data);
setValue("platform_links", values);
setOpen(false);
setLocalFormValue("platform", Platform.WEBSITE);
setLocalFormValue("link", "");
};
return (
<Modal
style={{
content: {
width: "400px",
height: "300px",
margin: "auto",
padding: 0,
overflow: "hidden",
boxShadow: "0 25px 50px -12px rgb(0 0 0 / 0.25)",
borderRadius: ".8rem",
border: ".1rem solid #d4d4d4",
},
overlay: {
zIndex: 60,
},
}}
isOpen={isOpen}
contentLabel="link details"
onRequestClose={() => {
setOpen(false);
}}
>
<div className="h-full bg-neutral-200">
<section className="relative bg-gradient-to-t from-neutral-800 to-neutral-600 p-2 drop-shadow-xl">
<h1 className="text-center text-lg font-bold text-neutral-200">
Platform Details
</h1>
<button
onClick={() => {
setOpen(false);
}}
type="button"
className="group absolute bottom-0 right-0 top-0 h-full px-4"
>
<XCircleSolid className="hidden h-6 w-6 text-white group-hover:block" />
<XCircleOutline className="block h-6 w-6 text-white group-hover:hidden" />
</button>
</section>
<form
onSubmit={(event) => {
handleSubmit(onSubmit)(event).catch((error) => {
console.error(error);
});
}}
className="space-y-4 p-4"
>
<DropdownSelector
options={platformTypeOptions}
label="Type"
details={register("platform", { required: true })}
/>
<GenericInput
placeholder="platform URL"
label="Link"
type="text"
details={register("link", { required: true })}
/>
<section className="py-4">
<button
type="submit"
className="mx-auto block w-fit rounded-md border border-neutral-900 bg-yellow-200 px-4 py-2 align-middle font-semibold shadow shadow-black/50 duration-200 ease-out hover:bg-yellow-300 hover:shadow-md"
>
<span className="hidden sm:inline-block">Create</span>
</button>
</section>
</form>
</div>
</Modal>
);
};
/**
* Contains the input fields for editing the links for a resource
* @returns
*/
const ResourceLinkSubForm = () => {
const { setValue, getValues, watch } = useFormContext<ResourceUpdateInput>();
const [linkModalOpen, setLinkModalOpen] = useState(false);
const [selectedLinks, setSelectedLinks] = useState<PlatformLink[]>(
getValues().platform_links ?? []
);
useEffect(() => {
watch((value, { name }) => {
if (name === "platform_links") {
const validLinks = value.platform_links?.filter((value) => {
return value?.link && value?.platform;
}) as unknown as PlatformLink[];
setSelectedLinks(validLinks);
}
});
}, [watch]);
const removeLink = (key: number) => {
const newLinks = [...selectedLinks];
newLinks.splice(key, 1);
setValue("platform_links", newLinks);
};
return (
<div className="mx-4">
<LinkModal isOpen={linkModalOpen} setOpen={setLinkModalOpen} />
<div className="mb-2 flex flex-row justify-between space-x-2 border-b border-neutral-400">
<h1 className="text-xl">Links</h1>
<button
type="button"
className="h-6 rounded-full border border-neutral-900 bg-neutral-200 px-2 leading-tight hover:bg-yellow-400"
onClick={() => {
setLinkModalOpen(!linkModalOpen);
}}
>
<span className="my-auto inline-block align-middle text-sm font-normal text-neutral-700">
Add
</span>
<PlusIcon className="my-auto inline-block w-4 align-middle" />
</button>
</div>
<div className="mx-auto flex w-48 flex-col space-y-2">
{selectedLinks.map((link, index) => {
return (
<section key={index} className="flex flex-row space-x-2">
<span className="grow-1 w-full">
<PlatformLinkButton platformLink={link} />
</span>
<button
onClick={() => {
removeLink(index);
}}
type="button"
className="my-auto h-9 w-9 grow-0 rounded-xl border border-red-100 bg-red-300 p-1 hover:bg-red-500"
>
<TrashIcon className="m-auto w-6" />
</button>
</section>
);
})}
</div>
</div>
);
};
const PaymentTypeOption = ({
type,
label,
}: {
type: PaymentType;
label: string;
}) => {
return (
<MultiSelectorOption value={type}>
<SelectedManyContext.Consumer>
{(selected) => (
<div
className={
(selected.includes(type) ? "bg-stone-800" : "bg-white") +
" flex flex-row space-x-2 whitespace-nowrap rounded-xl border border-neutral-400 py-[1px] pl-[1px] pr-2"
}
>
<span className="rounded-[10px] bg-white p-1">
<PriceIcon type={type} />
</span>
<span
className={
(selected.includes(type) ? "text-white" : "text black") +
" my-auto inline-block whitespace-nowrap text-sm"
}
>
{label}
</span>
</div>
)}
</SelectedManyContext.Consumer>
</MultiSelectorOption>
);
};
/**
* Resource summary inputs - ie description, manufacturer, etc.
*/
function ResourceSummarySubForm({
resource,
}: {
resource?: ResourceUpdateInput;
}) {
const { register } = useFormContext<ResourceUpdateInput>();
return (
<div className="space-y-4 px-4">
<div className="flex flex-row space-x-4 sm:mt-4">
<div className="flex w-20 flex-col justify-center space-y-2 sm:w-28">
<SelectImageInput />
</div>
<div className="flex flex-col justify-center overflow-hidden rounded-xl border border-neutral-400 bg-white drop-shadow-lg sm:w-[300px] md:w-[400px]">
<h2 className="border-b border-neutral-300 px-2 text-center font-semibold">
Resource Details
</h2>
<span className="text-md">
<InfoInputLine
details={register("manufacturer.name", { required: true })}
placeholder="manufacturer"
value={resource?.manufacturer?.name ?? ""}
hint="manufacturer"
/>
</span>
<InfoInputLine
details={register("name", { required: true })}
placeholder="name"
value={resource?.name ?? ""}
hint="name"
/>
<span className="my-1 block w-full text-center text-xs italic text-neutral-400">
Edit the fields above
</span>
</div>
</div>
<MultiSelectorMany
details={register("payment_options", { required: true })}
label="Price Category"
defaultValues={
resource?.payment_options ?? [PaymentType.FREE.toString()]
}
>
<PaymentTypeOption type={PaymentType.FREE} label="Free" />
<PaymentTypeOption
type={PaymentType.SUBSCRIPTION_MONTHLY}
label="Monthly Subscription"
/>
<PaymentTypeOption
type={PaymentType.SUBSCRIPTION_WEEKLY}
label="Weekly Subscription"
/>
</MultiSelectorMany>
<MultiSelectorMany
details={register("skill_levels", { required: true })}
label="Skill Level"
defaultValues={resource?.skill_levels ?? []}
>
{Object.values(SkillLevel).map((skillLevel, index) => {
return (
<SimpleSelectorManyOption
key={index}
type={skillLevel}
label={skillLevel.toLowerCase()}
/>
);
})}
</MultiSelectorMany>
<MultiSelectorMany
details={register("skills", { required: true })}
label="Skills Covered"
defaultValues={resource?.skills ?? []}
>
{Object.values(Skill).map((skill, index) => {
return (
<SimpleSelectorManyOption
key={index}
type={skill}
label={skill.toLowerCase()}
/>
);
})}
</MultiSelectorMany>
</div>
);
}
const ResourceDescriptionSubForm = () => {
const [dropdownOpen, toggleDropdown] = useState(false);
const { register } = useFormContext();
return (
<div className="mx-4">
<label className="text-md font-semibold">Description</label>
<div className="relative mt-4 overflow-hidden rounded-xl border border-neutral-400 bg-neutral-200 text-left shadow">
<button
type="button"
onClick={() => {
toggleDropdown(!dropdownOpen);
}}
className="group flex w-full flex-row justify-between border-b-[4px] border-neutral-700 bg-neutral-600 p-2 align-middle"
>
<section className="space-x-2">
<h3 className="inline text-sm font-bold text-neutral-100">
IMPORTANT
</h3>
<span className="inline italic text-neutral-300">open to edit</span>
</section>
<ChevronDownIcon className="mx-2 my-auto w-4 text-white group-hover:animate-bounce" />
</button>
<textarea
{...register("description", { required: true })}
className={
"h-48 w-full rounded-b-xl p-2" + (dropdownOpen ? " hidden" : "")
}
/>
<textarea
{...register("manufacturer.notice")}
className={
"h-48 w-full rounded-b-xl bg-neutral-800 p-2 text-white" +
(dropdownOpen ? "" : " hidden")
}
/>
</div>
</div>
);
};
const ResourceForm = ({
methods,
resource,
error,
}: {
resource?: ResourceUpdateInput;
methods: UseFormReturn<ResourceUpdateInput>;
error?: string;
}) => {
return (
<FormProvider {...methods}>
{error ? (
<h1 className="text-center font-semibold text-red-600">
Error Updating Resource:{" "}
<span className="font-normal text-red-400">{error}</span>
</h1>
) : undefined}
<form className="mx-auto flex max-w-2xl flex-col flex-col-reverse py-1 sm:flex-row sm:divide-x sm:py-4">
<div className="my-5 mr-4 flex flex-col text-lg font-bold">
<ResourceLinkSubForm />
</div>
<div>
<h1 className="mx-4 mb-2 border-b border-neutral-400 text-xl font-bold sm:hidden">
General
</h1>
<div className="justify-left mx-auto flex max-w-lg flex-col space-y-4 pb-5">
<ResourceSummarySubForm resource={resource} />
<ResourceDescriptionSubForm />
</div>
</div>
</form>
</FormProvider>
);
};
export { ResourceForm };