diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60106fd..2ca430b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/src/components/ResourceTable.tsx b/src/components/ResourceTable.tsx index a61f5b3..a4c40b3 100644 --- a/src/components/ResourceTable.tsx +++ b/src/components/ResourceTable.tsx @@ -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> diff --git a/src/pages/api/resources/photo/[id].tsx b/src/pages/api/resources/photo/[id].tsx index 3257ea3..73dfb12 100644 --- a/src/pages/api/resources/photo/[id].tsx +++ b/src/pages/api/resources/photo/[id].tsx @@ -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) { diff --git a/src/pages/resources/[id]/edit.tsx b/src/pages/resources/[id]/edit.tsx index 3ee4ea7..a8d9d15 100644 --- a/src/pages/resources/[id]/edit.tsx +++ b/src/pages/resources/[id]/edit.tsx @@ -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!" diff --git a/src/server/api/routers/auditoryResources.ts b/src/server/api/routers/auditoryResources.ts index 4ab1e86..1252dae 100644 --- a/src/server/api/routers/auditoryResources.ts +++ b/src/server/api/routers/auditoryResources.ts @@ -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, diff --git a/src/utils/api.ts b/src/utils/api.ts index f4f4ad5..984777a 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -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() {