Skip to content

Commit

Permalink
Add support for drag drop file upload (#117)
Browse files Browse the repository at this point in the history
Also create an award item with the same url
  • Loading branch information
byronwall committed Oct 16, 2023
1 parent 6c9acdc commit e52acf5
Show file tree
Hide file tree
Showing 8 changed files with 572 additions and 54 deletions.
380 changes: 346 additions & 34 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@types/bcrypt": "^5.0.0",
"@types/uuid": "^9.0.4",
"@vercel/analytics": "^1.0.2",
"aws-sdk": "^2.1473.0",
"bcrypt": "^5.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
Expand All @@ -43,6 +44,7 @@
"openai": "^4.4.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.46.1",
"superjson": "^1.13.1",
"tailwind-merge": "^1.14.0",
Expand Down
43 changes: 23 additions & 20 deletions src/app/admin/awards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { ButtonLoading } from "~/components/common/ButtonLoading";
import { Textarea } from "~/components/ui/textarea";
import { useQuerySsr } from "~/hooks/useQuerySsr";
import { ImageDropzone } from "~/components/dropzone/ImageDropzone";

export default function AdminAwardPage() {
return <AdminAwards />;
Expand Down Expand Up @@ -47,26 +48,28 @@ function AdminAwards() {
<section>
<h1>awards</h1>

<Card className="max-w-4xl">
<CardHeader>
<CardTitle>Add images to award choices</CardTitle>
<CardDescription>
Paste a set of image URLs here to add to DB
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={imageUrls}
onChange={(e) => setImageUrls(e.target.value)}
/>
<ButtonLoading
onClick={handleAddAwardImages}
isLoading={addAwardImages.isLoading}
>
Add URLs
</ButtonLoading>
</CardContent>
</Card>
<ImageDropzone>
<Card className="max-w-4xl">
<CardHeader>
<CardTitle>Add images to award choices</CardTitle>
<CardDescription>
Paste a set of image URLs here to add to DB
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={imageUrls}
onChange={(e) => setImageUrls(e.target.value)}
/>
<ButtonLoading
onClick={handleAddAwardImages}
isLoading={addAwardImages.isLoading}
>
Add URLs
</ButtonLoading>
</CardContent>
</Card>
</ImageDropzone>

<Card className="max-w-4xl">
<CardHeader>
Expand Down
74 changes: 74 additions & 0 deletions src/components/dropzone/ImageDropzone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useCallback } from "react";
import { type DropzoneOptions, useDropzone } from "react-dropzone";

import { trpc } from "~/lib/trpc/client";
import { cn } from "~/lib/utils";

type ChildrenRenderProps = {
isLoading: boolean;
};

type Props = {
className?: string;

children: React.ReactNode | ((props: ChildrenRenderProps) => React.ReactNode);
};

export function ImageDropzone({ children, className }: Props) {
// bring over the hooks

const utils = trpc.useContext();

const uploadDocumentMutation = trpc.awardRouter.uploadImageToS3.useMutation();

const onDropRaw: DropzoneOptions["onDrop"] = (acceptedFiles, _, e) => {
const file = acceptedFiles[0];

if (!file) return;

const reader = new FileReader();

reader.onload = async () => {
const result = reader.result;

if (typeof result !== "string") return;

const base64String = result.split(",")[1];

if (!base64String) return;

await uploadDocumentMutation.mutateAsync({
filename: file.name,
fileDataBase64: base64String,
fileMimeType: file.type,
});

// invalidate everything for now
await utils.invalidate();
};

reader.readAsDataURL(file);
};

const onDrop = useCallback(onDropRaw, [uploadDocumentMutation, utils]);

const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
noClick: true,
noDragEventsBubbling: true,
});

return (
<div className={cn({ "ring-primary ring-2": isDragActive }, className)}>
<div {...getRootProps()}>
<input {...getInputProps()} />

{typeof children === "function"
? children({ isLoading: uploadDocumentMutation.isLoading })
: children}
</div>
</div>
);
}
9 changes: 9 additions & 0 deletions src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const env = createEnv({
JWT_SECRET: z.string().min(1),
NEXT_PUBLIC_VERCEL_URL: z.string(),
OPENAI_API_KEY: z.string().min(1),

S3_ACCESS_KEY_ID: z.string().min(1),
S3_SECRET_ACCESS_KEY: z.string().min(1),
S3_ENDPOINT: z.string().min(1),
S3_BUCKET_NAME: z.string().min(1),
},

/**
Expand All @@ -46,6 +51,10 @@ export const env = createEnv({
JWT_SECRET: process.env.JWT_SECRET,
NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY,
S3_ENDPOINT: process.env.S3_ENDPOINT,
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
Expand Down
46 changes: 46 additions & 0 deletions src/pages/api/images/[id].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { type NextApiHandler } from "next";

import { env } from "~/env.mjs";
import { s3 } from "~/server/api/routers/awardRouter";
import { getServerAuthSession } from "~/server/auth";

const handler: NextApiHandler = async (req, res) => {
// hit the database and return the file

const { id } = req.query;

const session = await getServerAuthSession({ req, res });

if (!session) {
return res.status(401).json({ message: "Unauthorized" });
}

// load image from S3

const data = await s3
.getObject({
Bucket: env.S3_BUCKET_NAME,
Key: id as string,
})
.promise();

if (!data) {
return res.status(404).json({ message: "Not found" });
}

// convert blob data to buffer

// Determine the content type (e.g., 'image/jpeg', 'image/png')
const contentType = data.ContentType ?? "image/png";
const doc = data.Body as any;

res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", doc.length);

// include file name
res.setHeader("Content-Disposition", `attachment; filename=${doc.filename}`);

res.end(doc);
};

export default handler;
10 changes: 10 additions & 0 deletions src/pages/api/trpc/[trpc].ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ export default createNextApiHandler({
}
: undefined,
});


export const config = {
api: {
bodyParser: {
sizeLimit: '4mb'
},
responseLimit: '4mb'
},
}
62 changes: 62 additions & 0 deletions src/server/api/routers/awardRouter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { z } from "zod";
import AWS from "aws-sdk";

import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { prisma } from "~/server/db";
import { env } from "~/env.mjs";

export const s3 = new AWS.S3({
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
endpoint: env.S3_ENDPOINT,
s3ForcePathStyle: true,
signatureVersion: "v4",
});

export const awardRouter = createTRPCRouter({
getAllAwardsForProfile: protectedProcedure.query(({ ctx }) => {
Expand Down Expand Up @@ -140,7 +150,59 @@ export const awardRouter = createTRPCRouter({
},
});
}),

uploadImageToS3: protectedProcedure
.input(
z.object({
filename: z.string(),
fileDataBase64: z.string(),
fileMimeType: z.string(),
})
)
.mutation(async ({ input }) => {
// Use the function
await uploadToMinio(input);

// use that URL to create an award image
await prisma.awardImages.create({
data: {
imageUrl: "/api/images/" + input.filename,
metaInfo: {},
},
});

return true;
}),
});

async function uploadToMinio({
filename,
fileDataBase64,
fileMimeType,
}: {
filename: string;
fileDataBase64: string;
fileMimeType: string;
}) {
const fileContent = Buffer.from(fileDataBase64, "base64");

// Set up S3 upload parameters

try {
// Upload the image to the MinIO bucket
const data = await s3
.upload({
Bucket: env.S3_BUCKET_NAME,
Key: filename,
Body: fileContent,
ContentType: fileMimeType,
})
.promise();
console.log(`File uploaded successfully. ${data.Location}`);
} catch (error) {
console.log("Error uploading the file: ", error);
}
}
export async function getProfileSentenceCount(profileId: any) {
// TODO: this query is very inefficient -- do better
const groupedResults = await prisma.profileQuestionResult.groupBy({
Expand Down

0 comments on commit e52acf5

Please sign in to comment.