18 Commits

Author SHA1 Message Date
48e3eb49d2 Update README.md 2023-09-05 23:28:41 -05:00
8efb1e045d Merge pull request #4 from brandonegg/create-resource
Create/Delete Resource Admin Functions
2023-09-05 23:26:32 -05:00
23e9f91323 minor styling changes 2023-09-05 21:47:50 -05:00
2ef07fd37a add delete function 2023-09-05 20:38:05 -05:00
f66c35a225 add confirm button for dangerous admin actions 2023-09-05 20:28:03 -05:00
8b42377453 reduce redundant trpc error handling code to function 2023-09-05 20:03:38 -05:00
ee7268e724 make input strict 2023-09-05 19:48:02 -05:00
cad4b78f47 add input fields for age range 2023-09-05 19:45:32 -05:00
634f35657e improve readability of server error for user 2023-09-05 19:45:05 -05:00
35c301a686 Merge remote-tracking branch 'origin/main' into create-resource 2023-09-05 18:51:48 -05:00
2edc5d57b6 improve resource schema to reflect new icon structure 2023-09-04 00:35:57 -05:00
f7144e7cf4 switch to handle submit 2023-09-04 00:25:54 -05:00
cd1dc2a555 add create resource trpc function 2023-09-04 00:03:42 -05:00
c243fda8e1 fix link add button alignment 2023-08-30 22:22:32 -05:00
7fc0895177 fix issue where edit page resource query was refetching causing icon file to be reset 2023-08-30 22:13:41 -05:00
6e4efe2842 reuse form to create resource create page 2023-08-30 22:08:51 -05:00
2e2f99e0ec add admin bar with create button 2023-08-30 21:48:04 -05:00
33b30264d9 Merge pull request #3 from brandonegg/add-next-auth-middleware
add redirect to login when user on page which requires account
2023-08-30 21:37:13 -05:00
14 changed files with 395 additions and 123 deletions

View File

@ -8,6 +8,7 @@
There are many auditory training resources that often have unique requirements. A patient may require an Android application, or help with a specific skill. The patient may also already be at a specific skill level, and want a resource that is more challenging or easier depending on age group. The goal of this application is to provide an accessible website for patients and providers to easily find recommendations for auditory training development resources based on their unique requirements. There are many auditory training resources that often have unique requirements. A patient may require an Android application, or help with a specific skill. The patient may also already be at a specific skill level, and want a resource that is more challenging or easier depending on age group. The goal of this application is to provide an accessible website for patients and providers to easily find recommendations for auditory training development resources based on their unique requirements.
## Production Deployment ## Production Deployment
### Docker Method
Currently this is being deployed through docker on a single VPS. Using the included docker-compose you can start the production server with the following command: Currently this is being deployed through docker on a single VPS. Using the included docker-compose you can start the production server with the following command:
```sh ```sh
docker-compose up --build -d docker-compose up --build -d
@ -18,11 +19,8 @@ I have this hosted behind a reverse proxy (nginx) which routes traffic from the
A more permanent hosting solution through the University is in the works. A more permanent hosting solution through the University is in the works.
## Local Deployment ### Netlify
TODO As of recently, the project has been migrated to netlify enabling us to automate the deployment process and provide a more reliable experience for the end user.
## API Access
Documentation and updates to our RPC backend API are planned for the future. As of now there is no official documentation for accessing our resources database. More information to come!
## Directory Breakdown ## Directory Breakdown
Many directories have associated README's to provide related directory information. Many directories have associated README's to provide related directory information.

View File

@ -70,7 +70,7 @@ type Photo {
model AuditoryResource { model AuditoryResource {
id String @id @default(auto()) @map("_id") @db.ObjectId id String @id @default(auto()) @map("_id") @db.ObjectId
icon String icon String?
name String name String
description String description String
photo Photo? photo Photo?

View File

@ -8,7 +8,7 @@ type ResourcePhotoProps = (
src: string | undefined; src: string | undefined;
} }
| { | {
src: string; src: string | undefined;
photo: null; photo: null;
} }
) & { name: string }; ) & { name: string };

View File

@ -1,4 +1,6 @@
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react";
const AdminActionBody = ({ const AdminActionBody = ({
label, label,
@ -38,17 +40,67 @@ const AdminActionLink = ({
); );
}; };
const AdminActionConfirmButton = ({
label,
onConfirm,
symbol,
type = "button",
}: {
label: string;
onConfirm?: () => void;
symbol: JSX.Element | undefined;
type?: HTMLButtonElement["type"];
}) => {
const [isConfirmView, setConfirmView] = useState(false);
useEffect(() => {
if (!isConfirmView) {
return;
}
setTimeout(() => {
if (isConfirmView) {
setConfirmView(false);
}
}, 5000);
}, [isConfirmView]);
if (isConfirmView) {
return (
<AdminActionButton
symbol={<ExclamationCircleIcon className="w-4 animate-ping" />}
label={`Confirm ${label}`}
onClick={onConfirm}
type={type}
/>
);
}
return (
<AdminActionButton
symbol={symbol}
label={label}
onClick={() => {
setConfirmView(true);
}}
/>
);
};
const AdminActionButton = ({ const AdminActionButton = ({
label, label,
onClick, onClick,
symbol, symbol,
type = "button",
}: { }: {
label: string; label: string;
onClick: () => void; onClick?: () => void;
symbol: JSX.Element | undefined; symbol: JSX.Element | undefined;
type?: HTMLButtonElement["type"];
}) => { }) => {
return ( return (
<button <button
type={type}
className="py-auto group my-auto h-full space-x-2 rounded-lg border border-neutral-400 bg-neutral-800 px-2 hover:border-neutral-800 hover:bg-white" className="py-auto group my-auto h-full space-x-2 rounded-lg border border-neutral-400 bg-neutral-800 px-2 hover:border-neutral-800 hover:bg-white"
onClick={onClick} onClick={onClick}
> >
@ -62,4 +114,4 @@ const AdminActionButton = ({
); );
}; };
export { AdminActionLink, AdminActionButton }; export { AdminActionLink, AdminActionButton, AdminActionConfirmButton };

View File

@ -41,12 +41,14 @@ import Modal from "react-modal";
import { type RouterInputs } from "~/utils/api"; import { type RouterInputs } from "~/utils/api";
import { PlatformLinkButton } from "~/pages/resources/[id]"; import { PlatformLinkButton } from "~/pages/resources/[id]";
import { ResourcePhoto } from "~/components/ResourcePhoto"; import { ResourcePhoto } from "~/components/ResourcePhoto";
import { FieldLabel } from "~/components/forms/inputLabel";
// Required for accessibility // Required for accessibility
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
Modal.setAppElement("#__next"); Modal.setAppElement("#__next");
export type ResourceUpdateInput = RouterInputs["auditoryResource"]["update"]; export type ResourceUpdateInput = RouterInputs["auditoryResource"]["update"];
export type ResourceCreateInput = RouterInputs["auditoryResource"]["create"];
/** /**
* Renders the image selector for resource form. * Renders the image selector for resource form.
@ -86,15 +88,22 @@ const SelectImageInput = () => {
htmlFor="resource-image-file" htmlFor="resource-image-file"
className="bg-whit group relative cursor-pointer overflow-hidden rounded-xl border border-neutral-400 drop-shadow-lg" className="bg-whit group relative cursor-pointer overflow-hidden rounded-xl border border-neutral-400 drop-shadow-lg"
> >
<ResourcePhoto {photo ? (
name={name ?? "unknown resource logo"} <>
photo={photo ?? null} <ResourcePhoto
src={icon} name={name ?? "unknown resource logo"}
/> photo={photo}
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 className="absolute bottom-0 left-0 right-0 top-0 hidden place-items-center group-hover:grid group-hover:bg-white/70">
</div> <PencilSquareIcon className="w-16 text-black/50" />
</div>
</>
) : (
<div className="grid aspect-square place-items-center hover:bg-white/70">
<PencilSquareIcon className="h-16 w-16 text-black/50" />
</div>
)}
</label> </label>
<input <input
onChange={onChange} onChange={onChange}
@ -259,15 +268,15 @@ const ResourceLinkSubForm = () => {
<h1 className="text-xl">Links</h1> <h1 className="text-xl">Links</h1>
<button <button
type="button" type="button"
className="h-6 rounded-full border border-neutral-900 bg-neutral-200 px-2 leading-tight hover:bg-yellow-400" className="flex h-6 flex-row items-center rounded-full border border-neutral-900 bg-neutral-200 px-2 leading-tight drop-shadow-sm hover:bg-yellow-400"
onClick={() => { onClick={() => {
setLinkModalOpen(!linkModalOpen); setLinkModalOpen(!linkModalOpen);
}} }}
> >
<span className="my-auto inline-block align-middle text-sm font-normal text-neutral-700"> <span className="text-sm font-normal leading-3 text-neutral-700">
Add Add
</span> </span>
<PlusIcon className="my-auto inline-block w-4 align-middle" /> <PlusIcon className="w-4 leading-3" />
</button> </button>
</div> </div>
@ -352,16 +361,18 @@ function ResourceSummarySubForm({
</h2> </h2>
<span className="text-md"> <span className="text-md">
<InfoInputLine <InfoInputLine
details={register("manufacturer.name", { required: true })} details={register("manufacturer.name", {
required: "Field required",
})}
placeholder="manufacturer" placeholder="manufacturer"
value={resource?.manufacturer?.name ?? ""}
hint="manufacturer" hint="manufacturer"
value={resource?.name}
/> />
</span> </span>
<InfoInputLine <InfoInputLine
details={register("name", { required: true })} details={register("name", { required: "Field required" })}
placeholder="name" placeholder="name"
value={resource?.name ?? ""} value={resource?.name}
hint="name" hint="name"
/> />
<span className="my-1 block w-full text-center text-xs italic text-neutral-400"> <span className="my-1 block w-full text-center text-xs italic text-neutral-400">
@ -369,12 +380,37 @@ function ResourceSummarySubForm({
</span> </span>
</div> </div>
</div> </div>
<div>
<FieldLabel
heading="Age Range"
subheading="Specify the minimum and maximum age range supported by the resource"
/>
<div className="mt-4 flex flex-row space-x-4">
<GenericInput
type="number"
placeholder="minimum age"
details={register("ages.min", {
required: "Field required",
valueAsNumber: true,
})}
/>
<span className="text-xl">-</span>
<GenericInput
type="number"
placeholder="maximum age"
details={register("ages.max", {
required: "Field required",
valueAsNumber: true,
})}
/>
</div>
</div>
<MultiSelectorMany <MultiSelectorMany
details={register("payment_options", { required: true })} details={register("payment_options", { required: "Field required" })}
label="Price Category" label="Price Category"
defaultValues={ defaultValues={resource?.payment_options ?? []}
resource?.payment_options ?? [PaymentType.FREE.toString()]
}
> >
<PaymentTypeOption type={PaymentType.FREE} label="Free" /> <PaymentTypeOption type={PaymentType.FREE} label="Free" />
<PaymentTypeOption <PaymentTypeOption
@ -388,7 +424,7 @@ function ResourceSummarySubForm({
</MultiSelectorMany> </MultiSelectorMany>
<MultiSelectorMany <MultiSelectorMany
details={register("skill_levels", { required: true })} details={register("skill_levels", { required: "Field required" })}
label="Skill Level" label="Skill Level"
defaultValues={resource?.skill_levels ?? []} defaultValues={resource?.skill_levels ?? []}
> >
@ -404,7 +440,7 @@ function ResourceSummarySubForm({
</MultiSelectorMany> </MultiSelectorMany>
<MultiSelectorMany <MultiSelectorMany
details={register("skills", { required: true })} details={register("skills", { required: "Field required" })}
label="Skills Covered" label="Skills Covered"
defaultValues={resource?.skills ?? []} defaultValues={resource?.skills ?? []}
> >
@ -446,7 +482,7 @@ const ResourceDescriptionSubForm = () => {
<ChevronDownIcon className="mx-2 my-auto w-4 text-white group-hover:animate-bounce" /> <ChevronDownIcon className="mx-2 my-auto w-4 text-white group-hover:animate-bounce" />
</button> </button>
<textarea <textarea
{...register("description", { required: true })} {...register("description", { required: "Field required" })}
className={ className={
"h-48 w-full rounded-b-xl p-2" + (dropdownOpen ? " hidden" : "") "h-48 w-full rounded-b-xl p-2" + (dropdownOpen ? " hidden" : "")
} }

View File

@ -0,0 +1,16 @@
export const FieldLabel = ({
heading,
subheading,
}: {
heading: string;
subheading: string;
}) => {
return (
<div>
<label className="text-md block font-semibold">{heading}</label>
<span className="block text-sm italic text-neutral-400">
{subheading}
</span>
</div>
);
};

View File

@ -4,6 +4,7 @@ import {
useFormContext, useFormContext,
type UseFormRegisterReturn, type UseFormRegisterReturn,
} from "react-hook-form"; } from "react-hook-form";
import { FieldLabel } from "./inputLabel";
// generics // generics
interface ToStringable { interface ToStringable {
@ -60,10 +61,7 @@ function MultiSelectorMany<T extends ToStringable>({
<SelectorContext.Provider value={{ type: "many", updateCallback }}> <SelectorContext.Provider value={{ type: "many", updateCallback }}>
<SelectedManyContext.Provider value={selected}> <SelectedManyContext.Provider value={selected}>
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-md block font-semibold">{label}</label> <FieldLabel heading={label} subheading="Select all that apply" />
<span className="block text-sm italic text-neutral-400">
Select all that apply
</span>
<input {...details} readOnly type="text" className="hidden" /> <input {...details} readOnly type="text" className="hidden" />
<div className="mt-2 space-x-2 space-y-2 overflow-x-auto"> <div className="mt-2 space-x-2 space-y-2 overflow-x-auto">
{children} {children}
@ -100,10 +98,7 @@ function MultiSelector<T extends ToStringable>({
> >
<SelectedUniqueContext.Provider value={selected}> <SelectedUniqueContext.Provider value={selected}>
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-md block font-semibold">{label}</label> <FieldLabel heading={label} subheading="Select one from below" />
<span className="block text-sm italic text-neutral-400">
Select one from below
</span>
<input {...details} readOnly type="text" className="hidden" /> <input {...details} readOnly type="text" className="hidden" />
<div className="space-x-2 space-y-2 overflow-x-auto">{children}</div> <div className="space-x-2 space-y-2 overflow-x-auto">{children}</div>
</div> </div>

View File

@ -14,7 +14,7 @@ function InfoInputLine<TFieldName extends InternalFieldName>({
hint, hint,
details, details,
}: { }: {
value: string; value?: string | undefined;
placeholder: string; placeholder: string;
hint?: string; hint?: string;
details: UseFormRegisterReturn<TFieldName>; details: UseFormRegisterReturn<TFieldName>;
@ -50,16 +50,18 @@ function GenericInput<TFieldName extends InternalFieldName>({
type = "text", type = "text",
details, details,
}: { }: {
label: string; label?: string;
placeholder?: string; placeholder?: string;
type: HTMLInputTypeAttribute; type: HTMLInputTypeAttribute;
details: UseFormRegisterReturn<TFieldName>; details: UseFormRegisterReturn<TFieldName>;
}) { }) {
return ( return (
<section className="w-full space-y-1"> <section className="w-full space-y-1">
<label className="text-md block px-1 font-semibold text-neutral-600"> {label ? (
{label} <label className="text-md block px-1 font-semibold text-neutral-600">
</label> {label}
</label>
) : undefined}
<input <input
className="block h-8 w-full rounded-lg border border-neutral-600 px-2 py-1" className="block h-8 w-full rounded-lg border border-neutral-600 px-2 py-1"
{...details} {...details}

View File

@ -1,6 +1,9 @@
import { XCircleIcon } from "@heroicons/react/20/solid"; import { XCircleIcon } from "@heroicons/react/20/solid";
import { AdminBarLayout } from "~/components/admin/ControlBar"; import { AdminBarLayout } from "~/components/admin/ControlBar";
import { AdminActionButton, AdminActionLink } from "~/components/admin/common"; import {
AdminActionButton,
AdminActionConfirmButton,
} from "~/components/admin/common";
import Image from "next/image"; import Image from "next/image";
import { import {
ResourceForm, ResourceForm,
@ -13,6 +16,7 @@ import { useRouter } from "next/router";
import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout";
import { QueryWaitWrapper } from "~/components/LoadingWrapper"; import { QueryWaitWrapper } from "~/components/LoadingWrapper";
import { type AuditoryResource } from "@prisma/client"; import { type AuditoryResource } from "@prisma/client";
import { parseTRPCErrorMessage } from "~/utils/parseTRPCError";
const EditResourcePage = () => { const EditResourcePage = () => {
const router = useRouter(); const router = useRouter();
@ -25,6 +29,9 @@ const EditResourcePage = () => {
retry(_failureCount, error) { retry(_failureCount, error) {
return error.data?.httpStatus !== 404; return error.data?.httpStatus !== 404;
}, },
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchInterval: false,
} }
); );
@ -37,8 +44,8 @@ const EditResourcePage = () => {
}); });
const { mutate } = api.auditoryResource.update.useMutation({ const { mutate } = api.auditoryResource.update.useMutation({
onSuccess: async (_resData) => { onSuccess: async (resData) => {
if (!data) { if (!resData) {
setServerError("An unexpected error has occured"); setServerError("An unexpected error has occured");
return; return;
} }
@ -46,7 +53,9 @@ const EditResourcePage = () => {
setServerError(undefined); setServerError(undefined);
await router.push(`/resources/${data.id}`); await router.push(`/resources/${data.id}`);
}, },
onError: (error) => setServerError(error.message), onError: (error) => {
setServerError(parseTRPCErrorMessage(error.message));
},
}); });
const onSubmit: SubmitHandler<ResourceUpdateInput> = (data) => { const onSubmit: SubmitHandler<ResourceUpdateInput> = (data) => {
@ -81,11 +90,15 @@ const EditResourcePage = () => {
onSubmit(formMethods.getValues()); onSubmit(formMethods.getValues());
}} }}
/>, />,
<AdminActionLink <AdminActionConfirmButton
key="cancel" key="cancel"
symbol={<XCircleIcon className="w-4" />} symbol={<XCircleIcon className="w-4" />}
label="Cancel" label="Cancel"
href={`/resources/${data.id}`} onConfirm={() => {
router.push(`/resources/${data.id}`).catch((error) => {
console.error(error);
});
}}
/>, />,
]} ]}
> >

View File

@ -6,10 +6,14 @@ import { type AuditoryResource, type PlatformLink } from "@prisma/client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { AdminBarLayout } from "~/components/admin/ControlBar"; import { AdminBarLayout } from "~/components/admin/ControlBar";
import { AdminActionLink } from "~/components/admin/common"; import {
AdminActionConfirmButton,
AdminActionLink,
} from "~/components/admin/common";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout";
import { QueryWaitWrapper } from "~/components/LoadingWrapper"; import { QueryWaitWrapper } from "~/components/LoadingWrapper";
import { TrashIcon } from "@heroicons/react/24/outline";
export const PlatformLinkButton = ({ export const PlatformLinkButton = ({
platformLink, platformLink,
@ -91,6 +95,15 @@ const ResourceViewPage = () => {
} }
); );
const { mutate: mutateDelete } = api.auditoryResource.delete.useMutation({
onSuccess: async () => {
await router.push(`/resources`);
},
onError: (error) => {
console.error(error);
},
});
const ConditionalView = (data: AuditoryResource) => { const ConditionalView = (data: AuditoryResource) => {
return ( return (
<div className="mx-auto flex max-w-2xl flex-col flex-col-reverse divide-x py-4 sm:flex-row"> <div className="mx-auto flex max-w-2xl flex-col flex-col-reverse divide-x py-4 sm:flex-row">
@ -122,13 +135,24 @@ const ResourceViewPage = () => {
return ( return (
<HeaderFooterLayout> <HeaderFooterLayout>
<AdminBarLayout <AdminBarLayout
actions={ actions={[
<AdminActionLink <AdminActionLink
key="edit"
symbol={<PencilSquareIcon className="w-4" />} symbol={<PencilSquareIcon className="w-4" />}
label="Edit Page" label="Edit Page"
href={`${router.asPath}/edit`} href={`${router.asPath}/edit`}
/> />,
} <AdminActionConfirmButton
key="delete"
label="Delete"
symbol={<TrashIcon className="w-4" />}
onConfirm={() => {
mutateDelete({
id,
});
}}
/>,
]}
> >
<div className="mb-12"> <div className="mb-12">
<QueryWaitWrapper query={resourceQuery} Render={ConditionalView} /> <QueryWaitWrapper query={resourceQuery} Render={ConditionalView} />

View File

@ -0,0 +1,86 @@
import { XCircleIcon, PlusCircleIcon } from "@heroicons/react/20/solid";
import { useRouter } from "next/router";
import { useState } from "react";
import {
type SubmitHandler,
useForm,
type UseFormReturn,
} from "react-hook-form";
import { AdminBarLayout } from "~/components/admin/ControlBar";
import {
AdminActionButton,
AdminActionConfirmButton,
} from "~/components/admin/common";
import {
type ResourceCreateInput,
ResourceForm,
type ResourceUpdateInput,
} from "~/components/admin/resources/form";
import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout";
import { api } from "~/utils/api";
import { parseTRPCErrorMessage } from "~/utils/parseTRPCError";
const EditResourcePage = () => {
const router = useRouter();
const formMethods = useForm<ResourceCreateInput>();
const [serverError, setServerError] = useState<string | undefined>(undefined);
const { mutate } = api.auditoryResource.create.useMutation({
onSuccess: async (resData) => {
if (!resData) {
setServerError("An unexpected error has occured");
}
setServerError(undefined);
await router.push(`/resources/${resData.id}`);
},
onError: (error) => {
setServerError(parseTRPCErrorMessage(error.message));
},
});
const onSubmit: SubmitHandler<ResourceCreateInput> = (data) => {
mutate(data);
};
return (
<HeaderFooterLayout>
<AdminBarLayout
actions={[
<AdminActionButton
key="create"
symbol={<PlusCircleIcon className="w-4" />}
label="Create"
onClick={() => {
formMethods
.handleSubmit(onSubmit)()
.catch((error) => console.error(error));
}}
/>,
<AdminActionConfirmButton
key="cancel"
symbol={<XCircleIcon className="w-4" />}
label="Cancel"
onConfirm={() => {
router.push("/resources").catch((error) => {
console.error(error);
});
}}
/>,
]}
>
<div className="mb-12">
<ResourceForm
methods={
formMethods as unknown as UseFormReturn<ResourceUpdateInput>
}
error={serverError}
/>
</div>
</AdminBarLayout>
</HeaderFooterLayout>
);
};
export default EditResourcePage;

View File

@ -1,5 +1,5 @@
import { LinkIcon } from "@heroicons/react/20/solid"; import { LinkIcon } from "@heroicons/react/20/solid";
import { PrinterIcon } from "@heroicons/react/24/solid"; import { PrinterIcon, PlusCircleIcon } from "@heroicons/react/24/solid";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import ResourceTable from "~/components/ResourceTable"; import ResourceTable from "~/components/ResourceTable";
@ -8,6 +8,42 @@ import { parseQueryData } from "~/utils/parseSearchForm";
import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout"; import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout";
import { type AuditoryResource } from "@prisma/client"; import { type AuditoryResource } from "@prisma/client";
import { QueryWaitWrapper } from "~/components/LoadingWrapper"; import { QueryWaitWrapper } from "~/components/LoadingWrapper";
import { AdminBarLayout } from "~/components/admin/ControlBar";
import { AdminActionLink } from "~/components/admin/common";
const PageHeader = ({ printLink }: { printLink: string }) => {
return (
<div className="mb-2 flex flex-row justify-between p-2 print:hidden sm:mb-4 sm:p-4">
<section className="space-y-2">
<h1 className="text-3xl font-bold">All Resources</h1>
<div className="">
<p className="inline">Fill out the </p>
<Link
href="/resources/search"
className="inline rounded-lg border border-neutral-800 bg-neutral-200 px-2 py-[4px] hover:bg-neutral-900 hover:text-white"
>
search form
<LinkIcon className="inline w-4" />
</Link>
<p className="inline">
{" "}
for a list of auditory training resource recommendations.
</p>
</div>
</section>
<section className="mt-auto">
<Link
href={printLink}
className="inline-block whitespace-nowrap 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 print:hidden sm:space-x-2"
>
<span className="hidden sm:inline-block">Print Results</span>
<PrinterIcon className="inline-block w-6" />
</Link>
</section>
</div>
);
};
const Resources = () => { const Resources = () => {
const router = useRouter(); const router = useRouter();
@ -49,39 +85,22 @@ const Resources = () => {
return ( return (
<HeaderFooterLayout> <HeaderFooterLayout>
<div className="mx-auto mb-12 mt-6 w-full max-w-6xl md:px-2"> <AdminBarLayout
<div className="mb-2 flex flex-row justify-between p-2 print:hidden sm:mb-4 sm:p-4"> actions={[
<section className="space-y-2"> <AdminActionLink
<h1 className="text-3xl font-bold">All Resources</h1> key="cancel"
<div className=""> symbol={<PlusCircleIcon className="w-4" />}
<p className="inline">Fill out the </p> label="Create New"
<Link href={`/resources/create`}
href="/resources/search" />,
className="inline rounded-lg border border-neutral-800 bg-neutral-200 px-2 py-[4px] hover:bg-neutral-900 hover:text-white" ]}
> >
search form <div className="mx-auto mb-12 mt-6 w-full max-w-6xl md:px-2">
<LinkIcon className="inline w-4" /> <PageHeader printLink={printLink} />
</Link>
<p className="inline">
{" "}
for a list of auditory training resource recommendations.
</p>
</div>
</section>
<section className="mt-auto"> <QueryWaitWrapper query={resourceQuery} Render={ConditionalTable} />
<Link
href={printLink}
className="inline-block whitespace-nowrap 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 print:hidden sm:space-x-2"
>
<span className="hidden sm:inline-block">Print Results</span>
<PrinterIcon className="inline-block w-6" />
</Link>
</section>
</div> </div>
</AdminBarLayout>
<QueryWaitWrapper query={resourceQuery} Render={ConditionalTable} />
</div>
</HeaderFooterLayout> </HeaderFooterLayout>
); );
}; };

View File

@ -16,6 +16,42 @@ const emptyStringToUndefined = (val: string | undefined | null) => {
return val; return val;
}; };
const AuditoryResourceSchema = z.object({
icon: z.string().min(1).optional().nullable(),
name: z.string().min(1),
description: z.string().min(1),
manufacturer: z.object({
name: z.string().min(1),
required: z.boolean().default(false),
notice: z.string().nullable().transform(emptyStringToUndefined),
}),
ages: z.object({ min: z.number().int(), max: z.number().int() }).refine(
(ages) => {
return ages.min < ages.max;
},
{
message: "Minimum supported age must be less than maximum supported age.",
}
),
skills: z.array(z.nativeEnum(Skill)),
skill_levels: z.array(z.nativeEnum(SkillLevel)),
payment_options: z.array(z.nativeEnum(PaymentType)),
photo: z
.object({
name: z.string(),
data: z.instanceof(Buffer),
})
.nullable(),
platform_links: z
.array(
z.object({
platform: z.nativeEnum(Platform),
link: z.string().min(1),
})
)
.default([]),
});
export const auditoryResourceRouter = createTRPCRouter({ export const auditoryResourceRouter = createTRPCRouter({
byId: publicProcedure byId: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
@ -45,47 +81,30 @@ export const auditoryResourceRouter = createTRPCRouter({
return ctx.prisma.auditoryResource.findMany(); return ctx.prisma.auditoryResource.findMany();
}), }),
update: protectedProcedure create: protectedProcedure
.input(AuditoryResourceSchema.strict())
.mutation(async ({ input, ctx }) => {
return await ctx.prisma.auditoryResource.create({
data: input,
});
}),
delete: protectedProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
icon: z.string().min(1).optional(),
name: z.string().min(1).optional(),
description: z.string().min(1).optional(),
manufacturer: z
.object({
name: z.string().min(1),
required: z.boolean(),
notice: z
.string()
.optional()
.nullable()
.transform(emptyStringToUndefined),
})
.optional(),
ages: z
.object({ min: z.number().int(), max: z.number().int() })
.optional(),
skills: z.array(z.nativeEnum(Skill)).optional(),
skill_levels: z.array(z.nativeEnum(SkillLevel)).optional(),
payment_options: z.array(z.nativeEnum(PaymentType)).optional(),
photo: z
.object({
name: z.string(),
data: z.instanceof(Buffer),
})
.nullable()
.optional(),
platform_links: z
.array(
z.object({
platform: z.nativeEnum(Platform),
link: z.string().min(1),
})
)
.optional(),
}) })
) )
.mutation(async ({ input, ctx }) => {
return await ctx.prisma.auditoryResource.delete({
where: {
id: input.id,
},
});
}),
update: protectedProcedure
.input(AuditoryResourceSchema.partial().extend({ id: z.string() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
return await ctx.prisma.auditoryResource.update({ return await ctx.prisma.auditoryResource.update({
where: { where: {

View File

@ -0,0 +1,12 @@
export const parseTRPCErrorMessage = (message: string) => {
try {
const zodErrors = JSON.parse(message) as unknown as { message: string }[];
return zodErrors
.map((error) => {
return error.message;
})
.join(", ");
} catch {
return message;
}
};