Compare commits
18 Commits
add-next-a
...
brandonegg
Author | SHA1 | Date | |
---|---|---|---|
48e3eb49d2 | |||
8efb1e045d | |||
23e9f91323 | |||
2ef07fd37a | |||
f66c35a225 | |||
8b42377453 | |||
ee7268e724 | |||
cad4b78f47 | |||
634f35657e | |||
35c301a686 | |||
2edc5d57b6 | |||
f7144e7cf4 | |||
cd1dc2a555 | |||
c243fda8e1 | |||
7fc0895177 | |||
6e4efe2842 | |||
2e2f99e0ec | |||
33b30264d9 |
@ -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.
|
||||
|
||||
## 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:
|
||||
```sh
|
||||
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.
|
||||
|
||||
## Local Deployment
|
||||
TODO
|
||||
|
||||
## 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!
|
||||
### Netlify
|
||||
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.
|
||||
|
||||
## Directory Breakdown
|
||||
Many directories have associated README's to provide related directory information.
|
||||
|
@ -70,7 +70,7 @@ type Photo {
|
||||
|
||||
model AuditoryResource {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
icon String
|
||||
icon String?
|
||||
name String
|
||||
description String
|
||||
photo Photo?
|
||||
|
@ -8,7 +8,7 @@ type ResourcePhotoProps = (
|
||||
src: string | undefined;
|
||||
}
|
||||
| {
|
||||
src: string;
|
||||
src: string | undefined;
|
||||
photo: null;
|
||||
}
|
||||
) & { name: string };
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const AdminActionBody = ({
|
||||
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 = ({
|
||||
label,
|
||||
onClick,
|
||||
symbol,
|
||||
type = "button",
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
symbol: JSX.Element | undefined;
|
||||
type?: HTMLButtonElement["type"];
|
||||
}) => {
|
||||
return (
|
||||
<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"
|
||||
onClick={onClick}
|
||||
>
|
||||
@ -62,4 +114,4 @@ const AdminActionButton = ({
|
||||
);
|
||||
};
|
||||
|
||||
export { AdminActionLink, AdminActionButton };
|
||||
export { AdminActionLink, AdminActionButton, AdminActionConfirmButton };
|
||||
|
@ -41,12 +41,14 @@ import Modal from "react-modal";
|
||||
import { type RouterInputs } from "~/utils/api";
|
||||
import { PlatformLinkButton } from "~/pages/resources/[id]";
|
||||
import { ResourcePhoto } from "~/components/ResourcePhoto";
|
||||
import { FieldLabel } from "~/components/forms/inputLabel";
|
||||
|
||||
// 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"];
|
||||
export type ResourceCreateInput = RouterInputs["auditoryResource"]["create"];
|
||||
|
||||
/**
|
||||
* Renders the image selector for resource form.
|
||||
@ -86,15 +88,22 @@ const SelectImageInput = () => {
|
||||
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>
|
||||
{photo ? (
|
||||
<>
|
||||
<ResourcePhoto
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid aspect-square place-items-center hover:bg-white/70">
|
||||
<PencilSquareIcon className="h-16 w-16 text-black/50" />
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
onChange={onChange}
|
||||
@ -259,15 +268,15 @@ const ResourceLinkSubForm = () => {
|
||||
<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"
|
||||
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={() => {
|
||||
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
|
||||
</span>
|
||||
<PlusIcon className="my-auto inline-block w-4 align-middle" />
|
||||
<PlusIcon className="w-4 leading-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -352,16 +361,18 @@ function ResourceSummarySubForm({
|
||||
</h2>
|
||||
<span className="text-md">
|
||||
<InfoInputLine
|
||||
details={register("manufacturer.name", { required: true })}
|
||||
details={register("manufacturer.name", {
|
||||
required: "Field required",
|
||||
})}
|
||||
placeholder="manufacturer"
|
||||
value={resource?.manufacturer?.name ?? ""}
|
||||
hint="manufacturer"
|
||||
value={resource?.name}
|
||||
/>
|
||||
</span>
|
||||
<InfoInputLine
|
||||
details={register("name", { required: true })}
|
||||
details={register("name", { required: "Field required" })}
|
||||
placeholder="name"
|
||||
value={resource?.name ?? ""}
|
||||
value={resource?.name}
|
||||
hint="name"
|
||||
/>
|
||||
<span className="my-1 block w-full text-center text-xs italic text-neutral-400">
|
||||
@ -369,12 +380,37 @@ function ResourceSummarySubForm({
|
||||
</span>
|
||||
</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
|
||||
details={register("payment_options", { required: true })}
|
||||
details={register("payment_options", { required: "Field required" })}
|
||||
label="Price Category"
|
||||
defaultValues={
|
||||
resource?.payment_options ?? [PaymentType.FREE.toString()]
|
||||
}
|
||||
defaultValues={resource?.payment_options ?? []}
|
||||
>
|
||||
<PaymentTypeOption type={PaymentType.FREE} label="Free" />
|
||||
<PaymentTypeOption
|
||||
@ -388,7 +424,7 @@ function ResourceSummarySubForm({
|
||||
</MultiSelectorMany>
|
||||
|
||||
<MultiSelectorMany
|
||||
details={register("skill_levels", { required: true })}
|
||||
details={register("skill_levels", { required: "Field required" })}
|
||||
label="Skill Level"
|
||||
defaultValues={resource?.skill_levels ?? []}
|
||||
>
|
||||
@ -404,7 +440,7 @@ function ResourceSummarySubForm({
|
||||
</MultiSelectorMany>
|
||||
|
||||
<MultiSelectorMany
|
||||
details={register("skills", { required: true })}
|
||||
details={register("skills", { required: "Field required" })}
|
||||
label="Skills Covered"
|
||||
defaultValues={resource?.skills ?? []}
|
||||
>
|
||||
@ -446,7 +482,7 @@ const ResourceDescriptionSubForm = () => {
|
||||
<ChevronDownIcon className="mx-2 my-auto w-4 text-white group-hover:animate-bounce" />
|
||||
</button>
|
||||
<textarea
|
||||
{...register("description", { required: true })}
|
||||
{...register("description", { required: "Field required" })}
|
||||
className={
|
||||
"h-48 w-full rounded-b-xl p-2" + (dropdownOpen ? " hidden" : "")
|
||||
}
|
||||
|
16
src/components/forms/inputLabel.tsx
Normal file
16
src/components/forms/inputLabel.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -4,6 +4,7 @@ import {
|
||||
useFormContext,
|
||||
type UseFormRegisterReturn,
|
||||
} from "react-hook-form";
|
||||
import { FieldLabel } from "./inputLabel";
|
||||
|
||||
// generics
|
||||
interface ToStringable {
|
||||
@ -60,10 +61,7 @@ function MultiSelectorMany<T extends ToStringable>({
|
||||
<SelectorContext.Provider value={{ type: "many", updateCallback }}>
|
||||
<SelectedManyContext.Provider value={selected}>
|
||||
<div className="flex flex-col">
|
||||
<label className="text-md block font-semibold">{label}</label>
|
||||
<span className="block text-sm italic text-neutral-400">
|
||||
Select all that apply
|
||||
</span>
|
||||
<FieldLabel heading={label} subheading="Select all that apply" />
|
||||
<input {...details} readOnly type="text" className="hidden" />
|
||||
<div className="mt-2 space-x-2 space-y-2 overflow-x-auto">
|
||||
{children}
|
||||
@ -100,10 +98,7 @@ function MultiSelector<T extends ToStringable>({
|
||||
>
|
||||
<SelectedUniqueContext.Provider value={selected}>
|
||||
<div className="flex flex-col">
|
||||
<label className="text-md block font-semibold">{label}</label>
|
||||
<span className="block text-sm italic text-neutral-400">
|
||||
Select one from below
|
||||
</span>
|
||||
<FieldLabel heading={label} subheading="Select one from below" />
|
||||
<input {...details} readOnly type="text" className="hidden" />
|
||||
<div className="space-x-2 space-y-2 overflow-x-auto">{children}</div>
|
||||
</div>
|
||||
|
@ -14,7 +14,7 @@ function InfoInputLine<TFieldName extends InternalFieldName>({
|
||||
hint,
|
||||
details,
|
||||
}: {
|
||||
value: string;
|
||||
value?: string | undefined;
|
||||
placeholder: string;
|
||||
hint?: string;
|
||||
details: UseFormRegisterReturn<TFieldName>;
|
||||
@ -50,16 +50,18 @@ function GenericInput<TFieldName extends InternalFieldName>({
|
||||
type = "text",
|
||||
details,
|
||||
}: {
|
||||
label: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type: HTMLInputTypeAttribute;
|
||||
details: UseFormRegisterReturn<TFieldName>;
|
||||
}) {
|
||||
return (
|
||||
<section className="w-full space-y-1">
|
||||
<label className="text-md block px-1 font-semibold text-neutral-600">
|
||||
{label}
|
||||
</label>
|
||||
{label ? (
|
||||
<label className="text-md block px-1 font-semibold text-neutral-600">
|
||||
{label}
|
||||
</label>
|
||||
) : undefined}
|
||||
<input
|
||||
className="block h-8 w-full rounded-lg border border-neutral-600 px-2 py-1"
|
||||
{...details}
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { XCircleIcon } from "@heroicons/react/20/solid";
|
||||
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 {
|
||||
ResourceForm,
|
||||
@ -13,6 +16,7 @@ import { useRouter } from "next/router";
|
||||
import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout";
|
||||
import { QueryWaitWrapper } from "~/components/LoadingWrapper";
|
||||
import { type AuditoryResource } from "@prisma/client";
|
||||
import { parseTRPCErrorMessage } from "~/utils/parseTRPCError";
|
||||
|
||||
const EditResourcePage = () => {
|
||||
const router = useRouter();
|
||||
@ -25,6 +29,9 @@ const EditResourcePage = () => {
|
||||
retry(_failureCount, error) {
|
||||
return error.data?.httpStatus !== 404;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchInterval: false,
|
||||
}
|
||||
);
|
||||
|
||||
@ -37,8 +44,8 @@ const EditResourcePage = () => {
|
||||
});
|
||||
|
||||
const { mutate } = api.auditoryResource.update.useMutation({
|
||||
onSuccess: async (_resData) => {
|
||||
if (!data) {
|
||||
onSuccess: async (resData) => {
|
||||
if (!resData) {
|
||||
setServerError("An unexpected error has occured");
|
||||
return;
|
||||
}
|
||||
@ -46,7 +53,9 @@ const EditResourcePage = () => {
|
||||
setServerError(undefined);
|
||||
await router.push(`/resources/${data.id}`);
|
||||
},
|
||||
onError: (error) => setServerError(error.message),
|
||||
onError: (error) => {
|
||||
setServerError(parseTRPCErrorMessage(error.message));
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<ResourceUpdateInput> = (data) => {
|
||||
@ -81,11 +90,15 @@ const EditResourcePage = () => {
|
||||
onSubmit(formMethods.getValues());
|
||||
}}
|
||||
/>,
|
||||
<AdminActionLink
|
||||
<AdminActionConfirmButton
|
||||
key="cancel"
|
||||
symbol={<XCircleIcon className="w-4" />}
|
||||
label="Cancel"
|
||||
href={`/resources/${data.id}`}
|
||||
onConfirm={() => {
|
||||
router.push(`/resources/${data.id}`).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
|
@ -6,10 +6,14 @@ import { type AuditoryResource, type PlatformLink } from "@prisma/client";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
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 { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout";
|
||||
import { QueryWaitWrapper } from "~/components/LoadingWrapper";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const PlatformLinkButton = ({
|
||||
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) => {
|
||||
return (
|
||||
<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 (
|
||||
<HeaderFooterLayout>
|
||||
<AdminBarLayout
|
||||
actions={
|
||||
actions={[
|
||||
<AdminActionLink
|
||||
key="edit"
|
||||
symbol={<PencilSquareIcon className="w-4" />}
|
||||
label="Edit Page"
|
||||
href={`${router.asPath}/edit`}
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
<AdminActionConfirmButton
|
||||
key="delete"
|
||||
label="Delete"
|
||||
symbol={<TrashIcon className="w-4" />}
|
||||
onConfirm={() => {
|
||||
mutateDelete({
|
||||
id,
|
||||
});
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="mb-12">
|
||||
<QueryWaitWrapper query={resourceQuery} Render={ConditionalView} />
|
||||
|
86
src/pages/resources/create.tsx
Normal file
86
src/pages/resources/create.tsx
Normal 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;
|
@ -1,5 +1,5 @@
|
||||
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 { useRouter } from "next/router";
|
||||
import ResourceTable from "~/components/ResourceTable";
|
||||
@ -8,6 +8,42 @@ import { parseQueryData } from "~/utils/parseSearchForm";
|
||||
import { HeaderFooterLayout } from "~/layouts/HeaderFooterLayout";
|
||||
import { type AuditoryResource } from "@prisma/client";
|
||||
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 router = useRouter();
|
||||
@ -49,39 +85,22 @@ const Resources = () => {
|
||||
|
||||
return (
|
||||
<HeaderFooterLayout>
|
||||
<div className="mx-auto mb-12 mt-6 w-full max-w-6xl md:px-2">
|
||||
<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>
|
||||
<AdminBarLayout
|
||||
actions={[
|
||||
<AdminActionLink
|
||||
key="cancel"
|
||||
symbol={<PlusCircleIcon className="w-4" />}
|
||||
label="Create New"
|
||||
href={`/resources/create`}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="mx-auto mb-12 mt-6 w-full max-w-6xl md:px-2">
|
||||
<PageHeader printLink={printLink} />
|
||||
|
||||
<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>
|
||||
<QueryWaitWrapper query={resourceQuery} Render={ConditionalTable} />
|
||||
</div>
|
||||
|
||||
<QueryWaitWrapper query={resourceQuery} Render={ConditionalTable} />
|
||||
</div>
|
||||
</AdminBarLayout>
|
||||
</HeaderFooterLayout>
|
||||
);
|
||||
};
|
||||
|
@ -16,6 +16,42 @@ const emptyStringToUndefined = (val: string | undefined | null) => {
|
||||
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({
|
||||
byId: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
@ -45,47 +81,30 @@ export const auditoryResourceRouter = createTRPCRouter({
|
||||
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(
|
||||
z.object({
|
||||
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 }) => {
|
||||
return await ctx.prisma.auditoryResource.update({
|
||||
where: {
|
||||
|
12
src/utils/parseTRPCError.ts
Normal file
12
src/utils/parseTRPCError.ts
Normal 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;
|
||||
}
|
||||
};
|
Reference in New Issue
Block a user