From 59e4dc471f44605e9cdb6f7f533533dfafc93eec Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 22 May 2023 15:50:48 +0330 Subject: [PATCH] fully added profile photo submission --- components/ClickAwayHandler.tsx | 4 +- components/Modal/UserSettings.tsx | 127 +++++++++++------- lib/api/controllers/users/updateUser.ts | 41 ++++-- lib/client/fileExists.ts | 9 ++ lib/client/getInitialData.ts | 2 +- lib/client/resizeImage.ts | 17 +++ package.json | 1 + pages/api/avatar/[id].ts | 38 ++++++ .../migration.sql | 1 - prisma/schema.prisma | 1 - store/account.ts | 6 +- types/global.ts | 12 +- yarn.lock | 5 + 13 files changed, 194 insertions(+), 70 deletions(-) create mode 100644 lib/client/fileExists.ts create mode 100644 lib/client/resizeImage.ts create mode 100644 pages/api/avatar/[id].ts rename prisma/migrations/{20230520142249_init => 20230522122002_init}/migration.sql (98%) diff --git a/components/ClickAwayHandler.tsx b/components/ClickAwayHandler.tsx index 7b3b0a8..2bddbd1 100644 --- a/components/ClickAwayHandler.tsx +++ b/components/ClickAwayHandler.tsx @@ -24,9 +24,9 @@ function useOutsideAlerter( onClickOutside(event); } } - document.addEventListener("mouseup", handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); return () => { - document.removeEventListener("mouseup", handleClickOutside); + document.removeEventListener("mousedown", handleClickOutside); }; }, [ref, onClickOutside]); } diff --git a/components/Modal/UserSettings.tsx b/components/Modal/UserSettings.tsx index 077e9c4..4394bbf 100644 --- a/components/Modal/UserSettings.tsx +++ b/components/Modal/UserSettings.tsx @@ -3,13 +3,15 @@ // This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. // You should have received a copy of the GNU General Public License along with this program. If not, see . -import { useState } from "react"; +import { useEffect, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCircleUser, faClose } from "@fortawesome/free-solid-svg-icons"; +import { faUser, faClose } from "@fortawesome/free-solid-svg-icons"; import Checkbox from "../Checkbox"; import useAccountStore from "@/store/account"; import { AccountSettings } from "@/types/global"; import { useSession } from "next-auth/react"; +import { resizeImage } from "@/lib/client/resizeImage"; +import fileExists from "@/lib/client/fileExists"; type Props = { toggleSettingsModal: Function; @@ -19,38 +21,54 @@ export default function UserSettings({ toggleSettingsModal }: Props) { const { update } = useSession(); const { account, updateAccount } = useAccountStore(); - let initialUser = { - name: account.name, - email: account.email, - collectionProtection: account.collectionProtection, - whitelistedUsers: account.whitelistedUsers, - }; + useEffect(() => { + const determineProfilePicSource = async () => { + const path = `/api/avatar/${account.id}`; + const imageExists = await fileExists(path).catch((e) => console.log(e)); + if (imageExists) setUser({ ...user, profilePic: path }); + }; - const [user, setUser] = useState(initialUser); + determineProfilePicSource(); + }, []); - const [selectedFile, setSelectedFile] = useState(null); + const [user, setUser] = useState({ + ...account, + profilePic: null, + }); - const handleFileChange = (e: any) => { - setSelectedFile(e.target.files[0]); - }; + const handleImageUpload = async (e: any) => { + const file: File = e.target.files[0]; - const stateIsTampered = () => { - return JSON.stringify(user) !== JSON.stringify(initialUser); + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + const allowedExtensions = ["png", "jpeg", "jpg"]; + + if (allowedExtensions.includes(fileExtension as string)) { + const resizedFile = await resizeImage(file); + + console.log(resizedFile.size); + + if ( + resizedFile.size < 1048576 // 1048576 Bytes == 1MB + ) { + const reader = new FileReader(); + + reader.onload = () => { + setUser({ ...user, profilePic: reader.result as string }); + }; + + reader.readAsDataURL(resizedFile); + } else { + console.log("Please select a PNG or JPEG file thats less than 1MB."); + } + } else { + console.log("Invalid file format."); + } }; const submit = async () => { await updateAccount(user); - initialUser = { - name: account.name, - email: account.email, - collectionProtection: account.collectionProtection, - whitelistedUsers: account.whitelistedUsers, - }; - - console.log({ email: user.email, name: user.name }); - - if (user.email !== initialUser.email || user.name !== initialUser.name) + if (user.email !== account.email || user.name !== account.name) update({ email: user.email, name: user.name }); }; @@ -60,7 +78,7 @@ export default function UserSettings({ toggleSettingsModal }: Props) {

Profile Settings

- {user.email !== initialUser.email || user.name !== initialUser.name ? ( + {user.email !== account.email || user.name !== account.name ? (

Note: The page will be refreshed to apply the changes of "Email" or "Display Name". @@ -100,19 +118,37 @@ export default function UserSettings({ toggleSettingsModal }: Props) { - {/*

+

Profile Photo

- // Image goes here - -
- -
+ {user.profilePic && user.profilePic !== "DELETE" ? ( +
+ Profile Photo +
+ setUser({ + ...user, + profilePic: "DELETE", + }) + } + className="absolute top-1 left-1 w-5 h-5 flex items-center justify-center border p-1 bg-white border-sky-100 rounded-full text-gray-500 hover:text-red-500 duration-100 cursor-pointer" + > + +
+
+ ) : ( + + )} +
-
*/} +

@@ -164,21 +200,18 @@ export default function UserSettings({ toggleSettingsModal }: Props) { you to additional collections in the box below, separated by spaces.

) : null} - {stateIsTampered() ? ( -
- Apply Settings -
- ) : null} +
+ Apply Settings +
); } diff --git a/lib/api/controllers/users/updateUser.ts b/lib/api/controllers/users/updateUser.ts index b075afc..f719939 100644 --- a/lib/api/controllers/users/updateUser.ts +++ b/lib/api/controllers/users/updateUser.ts @@ -5,9 +5,38 @@ import { prisma } from "@/lib/api/db"; import { AccountSettings } from "@/types/global"; +import fs from "fs"; +import path from "path"; export default async function (user: AccountSettings, userId: number) { - console.log(typeof user); + console.log(console.log(user.profilePic)); + + const profilePic = user.profilePic; + + if (profilePic && profilePic !== "DELETE") { + if ((user?.profilePic?.length as number) < 1572864) { + try { + const filePath = path.join( + process.cwd(), + `data/uploads/avatar/${userId}.jpg` + ); + + const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, ""); + + fs.writeFile(filePath, base64Data, "base64", function (err) { + console.log(err); + }); + } catch (err) { + console.log("Error saving image:", err); + } + } else { + console.log("A file larger than 1.5MB was uploaded."); + } + } else if (profilePic === "DELETE") { + fs.unlink(`data/uploads/avatar/${userId}.jpg`, (err) => { + if (err) console.log(err); + }); + } const updatedUser = await prisma.user.update({ where: { @@ -19,13 +48,9 @@ export default async function (user: AccountSettings, userId: number) { collectionProtection: user.collectionProtection, whitelistedUsers: user.whitelistedUsers, }, - select: { - name: true, - email: true, - collectionProtection: true, - whitelistedUsers: true, - }, }); - return { response: updatedUser, status: 200 }; + const { password, ...unsensitiveInfo } = updatedUser; + + return { response: unsensitiveInfo, status: 200 }; } diff --git a/lib/client/fileExists.ts b/lib/client/fileExists.ts new file mode 100644 index 0000000..db145d3 --- /dev/null +++ b/lib/client/fileExists.ts @@ -0,0 +1,9 @@ +export default async function fileExists(fileUrl: string): Promise { + try { + const response = await fetch(fileUrl, { method: "HEAD" }); + return response.ok; + } catch (error) { + console.error("Error checking file existence:", error); + return false; + } +} diff --git a/lib/client/getInitialData.ts b/lib/client/getInitialData.ts index a0acd6e..15fd355 100644 --- a/lib/client/getInitialData.ts +++ b/lib/client/getInitialData.ts @@ -22,7 +22,7 @@ export default function () { setCollections(); setTags(); setLinks(); - setAccount(data.user.email as string); + setAccount(data.user.email as string, data.user.id); } }, [status]); } diff --git a/lib/client/resizeImage.ts b/lib/client/resizeImage.ts new file mode 100644 index 0000000..17cbe56 --- /dev/null +++ b/lib/client/resizeImage.ts @@ -0,0 +1,17 @@ +import Resizer from "react-image-file-resizer"; + +export const resizeImage = (file: File): Promise => + new Promise((resolve) => { + Resizer.imageFileResizer( + file, + 150, // target width + 150, // target height + "JPEG", // output format + 100, // quality + 0, // rotation + (uri: any) => { + resolve(uri as Blob); + }, + "blob" // output type + ); + }); diff --git a/package.json b/package.json index 9d3a987..f5a8016 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "puppeteer-extra-plugin-stealth": "^2.11.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-image-file-resizer": "^0.4.8", "react-select": "^5.7.0", "typescript": "4.9.4", "zustand": "^4.3.3" diff --git a/pages/api/avatar/[id].ts b/pages/api/avatar/[id].ts new file mode 100644 index 0000000..ae85c21 --- /dev/null +++ b/pages/api/avatar/[id].ts @@ -0,0 +1,38 @@ +// Copyright (C) 2022-present Daniel31x13 +// This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3. +// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with this program. If not, see . + +import type { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "pages/api/auth/[...nextauth]"; +import path from "path"; +import fs from "fs"; + +export default async function (req: NextApiRequest, res: NextApiResponse) { + if (!req.query.id) + return res.status(401).json({ response: "Invalid parameters." }); + + const session = await getServerSession(req, res, authOptions); + + if (!session?.user?.email) + return res.status(401).json({ response: "You must be logged in." }); + + // TODO: If profile is private, hide it to other users... + + const filePath = path.join( + process.cwd(), + `data/uploads/avatar/${req.query.id}.jpg` + ); + + console.log(filePath); + const file = fs.existsSync(filePath) + ? fs.readFileSync(filePath) + : "File not found."; + + if (!fs.existsSync(filePath)) + res.setHeader("Content-Type", "text/plain").status(404); + else res.setHeader("Content-Type", "image/jpeg").status(200); + + return res.send(file); +} diff --git a/prisma/migrations/20230520142249_init/migration.sql b/prisma/migrations/20230522122002_init/migration.sql similarity index 98% rename from prisma/migrations/20230520142249_init/migration.sql rename to prisma/migrations/20230522122002_init/migration.sql index 6cb80d7..770d6a9 100644 --- a/prisma/migrations/20230520142249_init/migration.sql +++ b/prisma/migrations/20230522122002_init/migration.sql @@ -6,7 +6,6 @@ CREATE TABLE "User" ( "password" TEXT NOT NULL, "collectionProtection" BOOLEAN NOT NULL DEFAULT false, "whitelistedUsers" TEXT[] DEFAULT ARRAY[]::TEXT[], - "profilePhotoPath" TEXT NOT NULL DEFAULT '', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "User_pkey" PRIMARY KEY ("id") diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1c7e7f..5930434 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,7 +22,6 @@ model User { collectionsJoined UsersAndCollections[] collectionProtection Boolean @default(false) whitelistedUsers String[] @default([]) - profilePhotoPath String @default("") createdAt DateTime @default(now()) } diff --git a/store/account.ts b/store/account.ts index 5a0e3bb..3358ce6 100644 --- a/store/account.ts +++ b/store/account.ts @@ -9,20 +9,20 @@ import { AccountSettings } from "@/types/global"; type AccountStore = { account: User; - setAccount: (email: string) => void; + setAccount: (email: string, id: number) => void; updateAccount: (user: AccountSettings) => Promise; }; const useAccountStore = create()((set) => ({ account: {} as User, - setAccount: async (email) => { + setAccount: async (email, id) => { const response = await fetch(`/api/routes/users?email=${email}`); const data = await response.json(); console.log(data); - if (response.ok) set({ account: data.response }); + if (response.ok) set({ account: { ...data.response } }); }, updateAccount: async (user) => { const response = await fetch("/api/routes/users", { diff --git a/types/global.ts b/types/global.ts index 1200961..da3fff3 100644 --- a/types/global.ts +++ b/types/global.ts @@ -3,7 +3,8 @@ // This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. // You should have received a copy of the GNU General Public License along with this program. If not, see . -import { Collection, Link, Tag } from "@prisma/client"; +import { Collection, Link, Tag, User } from "@prisma/client"; +import { SetStateAction } from "react"; export interface ExtendedLink extends Link { tags: Tag[]; @@ -58,9 +59,6 @@ export type SearchSettings = { }; }; -export type AccountSettings = { - name: string; - email: string; - collectionProtection: boolean; - whitelistedUsers: string[]; -}; +export interface AccountSettings extends User { + profilePic: string | null; +} diff --git a/yarn.lock b/yarn.lock index 6081366..65023ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3140,6 +3140,11 @@ react-dom@18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-image-file-resizer@^0.4.8: + version "0.4.8" + resolved "https://registry.yarnpkg.com/react-image-file-resizer/-/react-image-file-resizer-0.4.8.tgz#85f4ae4469fd2867d961568af660ef403d7a79af" + integrity sha512-Ue7CfKnSlsfJ//SKzxNMz8avDgDSpWQDOnTKOp/GNRFJv4dO9L5YGHNEnj40peWkXXAK2OK0eRIoXhOYpUzUTQ== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"