Enable custom icon uploads #1
@@ -63,11 +63,17 @@ type Manufacturer {
 | 
				
			|||||||
  notice   String?
 | 
					  notice   String?
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Photo {
 | 
				
			||||||
 | 
					  name String
 | 
				
			||||||
 | 
					  data Bytes
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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?
 | 
				
			||||||
  manufacturer    Manufacturer?
 | 
					  manufacturer    Manufacturer?
 | 
				
			||||||
  ages            RangeInput
 | 
					  ages            RangeInput
 | 
				
			||||||
  skills          Skill[]
 | 
					  skills          Skill[]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,12 +9,39 @@ import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
 | 
				
			|||||||
import Image from "next/image";
 | 
					import Image from "next/image";
 | 
				
			||||||
import Link from "next/link";
 | 
					import Link from "next/link";
 | 
				
			||||||
import { translateEnumPlatform, translateEnumSkill } from "~/utils/enumWordLut";
 | 
					import { translateEnumPlatform, translateEnumSkill } from "~/utils/enumWordLut";
 | 
				
			||||||
import { type ChangeEvent } from "react";
 | 
					import { useEffect, type ChangeEvent, useState } from "react";
 | 
				
			||||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
 | 
					import { ChevronDownIcon } from "@heroicons/react/24/outline";
 | 
				
			||||||
import { type ParsedUrlQuery, type ParsedUrlQueryInput } from "querystring";
 | 
					import { type ParsedUrlQuery, type ParsedUrlQueryInput } from "querystring";
 | 
				
			||||||
import { useRouter } from "next/router";
 | 
					import { useRouter } from "next/router";
 | 
				
			||||||
import { PriceIcon } from "~/prices/Icons";
 | 
					import { PriceIcon } from "~/prices/Icons";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ResourcePhoto = ({ resource }: { resource: AuditoryResource }) => {
 | 
				
			||||||
 | 
					  const [blobSrc, setBlobSrc] = useState<string | undefined>(undefined);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (!resource.photo?.data) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const blob = new Blob([resource.photo.data], { type: "image/png" });
 | 
				
			||||||
 | 
					    setBlobSrc(URL.createObjectURL(blob));
 | 
				
			||||||
 | 
					  }, [resource.photo]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const commonProps = {
 | 
				
			||||||
 | 
					    width: 512,
 | 
				
			||||||
 | 
					    height: 512,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Image
 | 
				
			||||||
 | 
					      className="w-full rounded-xl border border-neutral-400 bg-white drop-shadow-lg"
 | 
				
			||||||
 | 
					      src={blobSrc ?? `/resource_logos/${resource.icon}`}
 | 
				
			||||||
 | 
					      alt={`${resource.name} logo`}
 | 
				
			||||||
 | 
					      {...commonProps}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ResourceInfo = ({
 | 
					export const ResourceInfo = ({
 | 
				
			||||||
  resource,
 | 
					  resource,
 | 
				
			||||||
  showMoreInfo,
 | 
					  showMoreInfo,
 | 
				
			||||||
@@ -46,13 +73,7 @@ export const ResourceInfo = ({
 | 
				
			|||||||
        {showMoreInfo ? (
 | 
					        {showMoreInfo ? (
 | 
				
			||||||
          <Link href={`resources/${resource.id}`}>
 | 
					          <Link href={`resources/${resource.id}`}>
 | 
				
			||||||
            <div className="flex w-20 flex-col justify-center space-y-2 sm:w-28">
 | 
					            <div className="flex w-20 flex-col justify-center space-y-2 sm:w-28">
 | 
				
			||||||
              <Image
 | 
					              <ResourcePhoto resource={resource} />
 | 
				
			||||||
                className="w-full rounded-xl border border-neutral-400 bg-white drop-shadow-lg"
 | 
					 | 
				
			||||||
                src={`/resource_logos/${resource.icon}`}
 | 
					 | 
				
			||||||
                alt={`${resource.name} logo`}
 | 
					 | 
				
			||||||
                width={512}
 | 
					 | 
				
			||||||
                height={512}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
              <span className="block rounded-lg border border-neutral-900 bg-neutral-900 py-[1px] text-center text-white hover:bg-neutral-500 print:hidden">
 | 
					              <span className="block rounded-lg border border-neutral-900 bg-neutral-900 py-[1px] text-center text-white hover:bg-neutral-500 print:hidden">
 | 
				
			||||||
                more info
 | 
					                more info
 | 
				
			||||||
              </span>
 | 
					              </span>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,9 @@
 | 
				
			|||||||
import { type NextApiHandler } from "next";
 | 
					import { type NextApiHandler } from "next";
 | 
				
			||||||
import formidable from "formidable";
 | 
					import formidable from "formidable";
 | 
				
			||||||
import * as path from "path";
 | 
					import * as fs from "fs";
 | 
				
			||||||
import { prisma } from "~/server/db";
 | 
					import { prisma } from "~/server/db";
 | 
				
			||||||
 | 
					import { getServerAuthSession } from "~/server/auth";
 | 
				
			||||||
 | 
					import { Role } from "@prisma/client";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Returns filename for a given filepath.
 | 
					 * Returns filename for a given filepath.
 | 
				
			||||||
@@ -17,6 +19,12 @@ const handler: NextApiHandler = async (req, res) => {
 | 
				
			|||||||
    return;
 | 
					    return;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const authSession = await getServerAuthSession({ req, res });
 | 
				
			||||||
 | 
					  if (!authSession?.user || authSession.user.role !== Role.ADMIN) {
 | 
				
			||||||
 | 
					    res.writeHead(401, "Not authorized");
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { id } = req.query;
 | 
					  const { id } = req.query;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (Array.isArray(id) || !id) {
 | 
					  if (Array.isArray(id) || !id) {
 | 
				
			||||||
@@ -29,17 +37,21 @@ const handler: NextApiHandler = async (req, res) => {
 | 
				
			|||||||
    keepExtensions: true,
 | 
					    keepExtensions: true,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const localUploadPath: Promise<string> = new Promise((resolve, reject) => {
 | 
					  const uploadPhoto: Promise<formidable.File> = new Promise(
 | 
				
			||||||
    form.parse(req, (_err, _fields, files) => {
 | 
					    (resolve, reject) => {
 | 
				
			||||||
      const photo = Array.isArray(files.photo) ? files.photo[0] : files.photo;
 | 
					      form.parse(req, (_err, _fields, files) => {
 | 
				
			||||||
      if (!photo) {
 | 
					        const photo = Array.isArray(files.photo) ? files.photo[0] : files.photo;
 | 
				
			||||||
        reject("Invalid file type sent (or none provided)");
 | 
					        if (!photo) {
 | 
				
			||||||
        return;
 | 
					          reject("Invalid file type sent (or none provided)");
 | 
				
			||||||
      }
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      resolve(path.join("uploads", getFileName(photo.filepath)));
 | 
					        resolve(photo);
 | 
				
			||||||
    });
 | 
					      });
 | 
				
			||||||
  });
 | 
					    }
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const photoBuffer = fs.readFileSync((await uploadPhoto).filepath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    await prisma.auditoryResource.update({
 | 
					    await prisma.auditoryResource.update({
 | 
				
			||||||
@@ -47,7 +59,12 @@ const handler: NextApiHandler = async (req, res) => {
 | 
				
			|||||||
        id,
 | 
					        id,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      data: {
 | 
					      data: {
 | 
				
			||||||
        icon: await localUploadPath,
 | 
					        photo: {
 | 
				
			||||||
 | 
					          name: getFileName(
 | 
				
			||||||
 | 
					            (await uploadPhoto).originalFilename ?? "resource ICON"
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          data: photoBuffer,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } catch (error: unknown) {
 | 
					  } catch (error: unknown) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -60,9 +60,6 @@ const EditResourcePage = (
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onSubmit: SubmitHandler<ResourceUpdateInput> = async (data) => {
 | 
					  const onSubmit: SubmitHandler<ResourceUpdateInput> = async (data) => {
 | 
				
			||||||
    // TODO: Fix file upload, currently it is not updating correctly on the server side
 | 
					 | 
				
			||||||
    // May also need to look into re-rendering static pages when icon changes
 | 
					 | 
				
			||||||
    // Also need to add authentication of route!
 | 
					 | 
				
			||||||
    if (updateIconFile) {
 | 
					    if (updateIconFile) {
 | 
				
			||||||
      const data = new FormData();
 | 
					      const data = new FormData();
 | 
				
			||||||
      data.append("photo", updateIconFile);
 | 
					      data.append("photo", updateIconFile);
 | 
				
			||||||
@@ -75,8 +72,6 @@ const EditResourcePage = (
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      console.log("uploading icon");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (uploadResponse.status !== 200) {
 | 
					      if (uploadResponse.status !== 200) {
 | 
				
			||||||
        setServerError(
 | 
					        setServerError(
 | 
				
			||||||
          "Failed uploading resource icon file. Changes did not save!"
 | 
					          "Failed uploading resource icon file. Changes did not save!"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -137,6 +137,7 @@ export const auditoryResourceRouter = createTRPCRouter({
 | 
				
			|||||||
        }),
 | 
					        }),
 | 
				
			||||||
      ]);
 | 
					      ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // TODO: The issue here is the photo binary data can't be sent over tRPC which will cause the request to be unparsable by the client
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        count,
 | 
					        count,
 | 
				
			||||||
        resources,
 | 
					        resources,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -17,6 +17,15 @@ const getBaseUrl = () => {
 | 
				
			|||||||
  return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
 | 
					  return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					superjson.registerCustom<Buffer, number[]>(
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    isApplicable: (v): v is Buffer => v instanceof Buffer,
 | 
				
			||||||
 | 
					    serialize: (v) => [...v],
 | 
				
			||||||
 | 
					    deserialize: (v) => Buffer.from(v),
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "buffer"
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/** A set of type-safe react-query hooks for your tRPC API. */
 | 
					/** A set of type-safe react-query hooks for your tRPC API. */
 | 
				
			||||||
export const api = createTRPCNext<AppRouter>({
 | 
					export const api = createTRPCNext<AppRouter>({
 | 
				
			||||||
  config() {
 | 
					  config() {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user