Enable custom icon uploads #1
@@ -63,11 +63,17 @@ type Manufacturer {
 | 
			
		||||
  notice   String?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Photo {
 | 
			
		||||
  name String
 | 
			
		||||
  data Bytes
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model AuditoryResource {
 | 
			
		||||
  id              String         @id @default(auto()) @map("_id") @db.ObjectId
 | 
			
		||||
  icon            String
 | 
			
		||||
  name            String
 | 
			
		||||
  description     String
 | 
			
		||||
  photo           Photo?
 | 
			
		||||
  manufacturer    Manufacturer?
 | 
			
		||||
  ages            RangeInput
 | 
			
		||||
  skills          Skill[]
 | 
			
		||||
 
 | 
			
		||||
@@ -9,12 +9,39 @@ import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline";
 | 
			
		||||
import Image from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
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 { type ParsedUrlQuery, type ParsedUrlQueryInput } from "querystring";
 | 
			
		||||
import { useRouter } from "next/router";
 | 
			
		||||
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 = ({
 | 
			
		||||
  resource,
 | 
			
		||||
  showMoreInfo,
 | 
			
		||||
@@ -46,13 +73,7 @@ export const ResourceInfo = ({
 | 
			
		||||
        {showMoreInfo ? (
 | 
			
		||||
          <Link href={`resources/${resource.id}`}>
 | 
			
		||||
            <div className="flex w-20 flex-col justify-center space-y-2 sm:w-28">
 | 
			
		||||
              <Image
 | 
			
		||||
                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}
 | 
			
		||||
              />
 | 
			
		||||
              <ResourcePhoto resource={resource} />
 | 
			
		||||
              <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
 | 
			
		||||
              </span>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
import { type NextApiHandler } from "next";
 | 
			
		||||
import formidable from "formidable";
 | 
			
		||||
import * as path from "path";
 | 
			
		||||
import * as fs from "fs";
 | 
			
		||||
import { prisma } from "~/server/db";
 | 
			
		||||
import { getServerAuthSession } from "~/server/auth";
 | 
			
		||||
import { Role } from "@prisma/client";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Returns filename for a given filepath.
 | 
			
		||||
@@ -17,6 +19,12 @@ const handler: NextApiHandler = async (req, res) => {
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
  if (Array.isArray(id) || !id) {
 | 
			
		||||
@@ -29,17 +37,21 @@ const handler: NextApiHandler = async (req, res) => {
 | 
			
		||||
    keepExtensions: true,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const localUploadPath: Promise<string> = new Promise((resolve, reject) => {
 | 
			
		||||
    form.parse(req, (_err, _fields, files) => {
 | 
			
		||||
      const photo = Array.isArray(files.photo) ? files.photo[0] : files.photo;
 | 
			
		||||
      if (!photo) {
 | 
			
		||||
        reject("Invalid file type sent (or none provided)");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
  const uploadPhoto: Promise<formidable.File> = new Promise(
 | 
			
		||||
    (resolve, reject) => {
 | 
			
		||||
      form.parse(req, (_err, _fields, files) => {
 | 
			
		||||
        const photo = Array.isArray(files.photo) ? files.photo[0] : files.photo;
 | 
			
		||||
        if (!photo) {
 | 
			
		||||
          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 {
 | 
			
		||||
    await prisma.auditoryResource.update({
 | 
			
		||||
@@ -47,7 +59,12 @@ const handler: NextApiHandler = async (req, res) => {
 | 
			
		||||
        id,
 | 
			
		||||
      },
 | 
			
		||||
      data: {
 | 
			
		||||
        icon: await localUploadPath,
 | 
			
		||||
        photo: {
 | 
			
		||||
          name: getFileName(
 | 
			
		||||
            (await uploadPhoto).originalFilename ?? "resource ICON"
 | 
			
		||||
          ),
 | 
			
		||||
          data: photoBuffer,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error: unknown) {
 | 
			
		||||
 
 | 
			
		||||
@@ -60,9 +60,6 @@ const EditResourcePage = (
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  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) {
 | 
			
		||||
      const data = new FormData();
 | 
			
		||||
      data.append("photo", updateIconFile);
 | 
			
		||||
@@ -75,8 +72,6 @@ const EditResourcePage = (
 | 
			
		||||
        }
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      console.log("uploading icon");
 | 
			
		||||
 | 
			
		||||
      if (uploadResponse.status !== 200) {
 | 
			
		||||
        setServerError(
 | 
			
		||||
          "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 {
 | 
			
		||||
        count,
 | 
			
		||||
        resources,
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,15 @@ const getBaseUrl = () => {
 | 
			
		||||
  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. */
 | 
			
		||||
export const api = createTRPCNext<AppRouter>({
 | 
			
		||||
  config() {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user