diff --git a/package.json b/package.json index 580c889..235be2b 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "react-syntax-highlighter": "^15.5.0", "react-to-print": "^2.14.12", "react-use-scroll-position": "^2.0.0", + "react-webcam": "^7.2.0", "redis-om": "^0.3.6", "rehype-raw": "^6.1.1", "tesseract.js": "^4.0.5", diff --git a/src/components/pastpaper/PastPaper.tsx b/src/components/pastpaper/PastPaper.tsx index 2f70820..eab7642 100644 --- a/src/components/pastpaper/PastPaper.tsx +++ b/src/components/pastpaper/PastPaper.tsx @@ -7,14 +7,48 @@ import { Divider, Flex, HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, Stack, + Tag, Text, + useDisclosure, useMediaQuery } from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; +import { useAuthState } from 'react-firebase-hooks/auth'; import { BiDownArrow, BiUpArrow } from 'react-icons/bi'; +import { firebase } from '~/lib/firebase'; +import { PastPaperDocType } from '~/lib/pastpaper/types'; +import { fromFirebaseTimeStamp } from '~/lib/util'; -export default function PastPaper() { +export default function PastPaper({ + model, + onDelete, + onDownVote, + onUpVote +}: { + model: PastPaperDocType; + onDownVote: () => void; + onUpVote: () => void; + onDelete: () => void; +}) { const [isUnder500] = useMediaQuery('(max-width: 500px)'); + const [user] = useAuthState(firebase.firebaseAuth); + const [canEdit, setEdit] = useState(false); + + useEffect(() => { + if (!user) return setEdit(false); + const isAuthorizedUser = user.uid === model.uploader_uid; + const isAdmin = user.uid === process.env.NEXT_PUBLIC_ADMIN_EMAIL; + setEdit(isAuthorizedUser || isAdmin); + }, [user, model]); + + const { onOpen, isOpen, onClose } = useDisclosure(); return ( <> @@ -25,12 +59,15 @@ export default function PastPaper() { mb={4} border={'1px solid var(--border-color)'}> -
- - Object Oriented Programming +
+ + {model.subject_name} + + {model.exam_type} + past_paper - + - Lorem ipsum + {model.uploader.displayName} - Repo: 200 + {/* Exam: {model.exam_type} */} + + {/* Repo: 200 */} - - @@ -80,17 +131,41 @@ export default function PastPaper() { noOfLines={2} fontStyle={'italic'} color={'var(--muted-text)'}> - Uploaded at {new Date().toDateString()} + Uploaded at {fromFirebaseTimeStamp(model.upload_at as any).toDateString()} - - - - + {canEdit && ( + + + + + + + Are you sure to delete? + + +
+ + + + +
+
+
+
+
+ )} diff --git a/src/components/pastpaper/UploadModal.tsx b/src/components/pastpaper/UploadModal.tsx index 83cc3bb..9dc1fb6 100644 --- a/src/components/pastpaper/UploadModal.tsx +++ b/src/components/pastpaper/UploadModal.tsx @@ -16,7 +16,13 @@ import { Text, VisuallyHidden, Switch, - useToast + useToast, + HStack, + useMediaQuery, + Modal, + ModalOverlay, + ModalContent, + Stack } from '@chakra-ui/react'; import React, { useReducer, useRef, useState, useCallback } from 'react'; @@ -38,6 +44,11 @@ import { v4 as uuidv4 } from 'uuid'; import { doc, serverTimestamp, setDoc } from 'firebase/firestore'; import { User } from 'firebase/auth'; import useAllSubjects from '~/hooks/useAllSubjects'; +import { FaCamera, FaImages } from 'react-icons/fa'; +import Webcam from 'react-webcam'; +import { dataURLtoFile } from '~/lib/util'; +import { useUserCredentials } from '~/hooks/hooks'; +import upload from '~/lib/pastpaper/upload'; const UploadModal = ({ isOpen, @@ -53,10 +64,16 @@ const UploadModal = ({ uploading: false }); + const [user] = useUserCredentials(); + + const [isWebcamOpen, setIsWebcamOpen] = useState(false); + const toast = useToast(); const subjects = useAllSubjects(); const inputRef = useRef(null); + const webcamRef = useRef(null); + const [selectedFile, setSelectedFile] = useState<{ img: null | File; err: undefined | string }>({ img: null, err: undefined @@ -67,6 +84,16 @@ const UploadModal = ({ inputRef.current.click(); }; + const captureImage = () => { + if (webcamRef.current == undefined) return; + const imageSrc = webcamRef.current.getScreenshot(); + if (imageSrc) { + const file = dataURLtoFile(imageSrc, 'captured_image.jpg'); + setSelectedFile({ err: undefined, img: file }); + setIsWebcamOpen(false); + } + }; + const inputReducer = (state: inputStateType, action: InputAction) => { switch (action.type) { case InputActionKind.subject: @@ -125,7 +152,6 @@ const UploadModal = ({ if (selectedFile.img == undefined) setSelectedFile({ img: null, err: InputError.image }); - console.log(errors); errors = selectedFile.img == null ? errors + 1 : errors; if (errors != 0) { @@ -148,8 +174,7 @@ const UploadModal = ({ }; // TODO: validate image - convertImageToText().then((res) => { - console.log(res?.data); + convertImageToText().then(async (res) => { if (!res?.data.lines.length) return img_err(); if (res?.data.lines.length < 10) return img_err(); @@ -158,8 +183,6 @@ const UploadModal = ({ return acc + curr.confidence; }, 0) / res.data.lines.length; - console.log(avgConfidence); - // all set toast({ status: 'info', @@ -176,9 +199,52 @@ const UploadModal = ({ duration: 1000, position: 'top' }); + + // lets rest everything + setLoading((prev) => ({ + ...prev, + uploading: true + })); + + await upload({ + file: selectedFile.img, + confidence: avgConfidence, + currUser: user!, + visibility: input.visibility.value, + examType: input.examType.value, + subject_name: input.subject.value + }); + + // reset everything + + setSelectedFile({ + err: '', + img: null + }); + + dispatch({ + type: '', + payload: '' + } as any); + + setLoading({ + uploading: false, + validatingImage: false + }); + + toast({ + status: 'success', + description: `Your past paper has been uploaded ✔`, + duration: 1000, + position: 'top' + }); + + onClose(); }); }; + const [isSmScreen] = useMediaQuery('(max-width: 500px)'); + return ( <> @@ -192,7 +258,7 @@ const UploadModal = ({ e.preventDefault(); handleSubmit(); }}> - +
@@ -228,18 +295,66 @@ const UploadModal = ({ )}
- - CLICK TO UPLOAD IMAGE - + {!selectedFile.img && ( + + +
+ + CLICK TO UPLOAD IMAGE +
+
+ OR + { + setIsWebcamOpen(true); + }} + style={{ display: selectedFile.img ? 'none' : 'initial' }}> +
+ + UPLOAD IMAGE FROM CAMERA +
+
+
+ )} + + { + setIsWebcamOpen(false); + }} + size={'xl'} + isCentered> + + + + + + + +
{selectedFile.err} @@ -289,7 +404,7 @@ const UploadModal = ({ - {loading.validatingImage && Please Wait Validating Image} + {loading.validatingImage && Please Wait Validating Image Using AI} {loading.uploading && Uploading Image to Cloud}
-// -// -// -// -// -// -// -// ); -// }; diff --git a/src/lib/pastpaper/crud.ts b/src/lib/pastpaper/crud.ts new file mode 100644 index 0000000..62dcc28 --- /dev/null +++ b/src/lib/pastpaper/crud.ts @@ -0,0 +1,28 @@ +import { arrayRemove, arrayUnion, doc, increment, updateDoc } from 'firebase/firestore'; +import { pastPapersCol } from '../firebase'; +import { PastPaperDocType } from './types'; + +export const deletePastPaper = async (uid: string) => { + const docRef = doc(pastPapersCol, uid); + return updateDoc(docRef, { + deleted: true + } as Partial); +}; + +export const pastPaperUpVote = async (uid: string, doerId: string) => { + const docRef = doc(pastPapersCol, uid); + return updateDoc(docRef, { + up_votes: arrayUnion(doerId) as any, + down_votes: arrayRemove(doerId) as any, + votes_count: increment(1) as any + } as Partial); +}; + +export const pastPaperDownVote = async (uid: string, doerId: string) => { + const docRef = doc(pastPapersCol, uid); + return updateDoc(docRef, { + down_votes: arrayUnion(doerId) as any, + up_votes: arrayRemove(doerId) as any, + votes_count: increment(-1) as any + } as Partial); +}; diff --git a/src/lib/pastpaper/index.ts b/src/lib/pastpaper/index.ts new file mode 100644 index 0000000..8da412b --- /dev/null +++ b/src/lib/pastpaper/index.ts @@ -0,0 +1,112 @@ +import { + collection, + orderBy, + query, + limit, + where, + startAt, + getDocs, + DocumentReference, + onSnapshot, + doc, + getDoc +} from 'firebase/firestore'; +import { firebase, pastPapersCol } from '../firebase'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { PastPaperDocType } from './types'; +import { useAuthState } from 'react-firebase-hooks/auth'; +import { fromFirebaseTimeStamp } from '../util'; + +const ARBITRARY_LIMIT = 1000; + +/** + * Fetch Basic feed for home page + */ +async function fetchFeed(lastDocRef: unknown) { + const q = lastDocRef + ? query( + pastPapersCol, + limit(ARBITRARY_LIMIT), + orderBy('upload_at', 'desc'), + startAt(lastDocRef) + ) + : query(pastPapersCol, limit(ARBITRARY_LIMIT), orderBy('upload_at', 'desc')); + return getDocs(q); +} + +export const usePastPaperPagination = (): [ + PastPaperDocType[], + { + fetchMore: () => void; + validate: (uid: string) => void; + } +] => { + const [pastPapers, setPastPapers] = useState([]); + const [user] = useAuthState(firebase.firebaseAuth); + const [userUploads, setUserUploads] = useState([]); + const [lastDocRef, setLastDocRef] = useState(undefined); + + useEffect(() => { + const fetchData = async () => { + const initialFeedSnapShot = await fetchFeed(undefined); + const initialFeed = initialFeedSnapShot.docs.map((doc) => doc.data() as PastPaperDocType); + setPastPapers(initialFeed); + setLastDocRef(initialFeedSnapShot.docs.at(-1)); + }; + + fetchData(); + }, []); + + // listen for current user uploads + useEffect(() => { + if (!user) return; + const q = query( + pastPapersCol, + where('uploader_uid', '==', user.uid), + orderBy('upload_at', 'desc') + ); + const unSub = onSnapshot(q, (snapShot) => { + const docs = snapShot.docs.map((doc) => doc.data() as PastPaperDocType); + setUserUploads(docs); + }); + + return () => unSub(); + }, [user]); + + const fetchMore = useCallback(async () => { + if (lastDocRef == undefined) return; + const snapShot = await fetchFeed(null); + const newDocs = snapShot.docs.map((doc) => doc.data() as PastPaperDocType); + setPastPapers((prev) => [...prev, ...newDocs]); + }, [lastDocRef]); + + const validate = useCallback(async (uid: string) => { + const docRef = doc(pastPapersCol, uid); + const snapShot = await getDoc(docRef); + if (!snapShot.exists) return; + setPastPapers((prev) => [snapShot.data() as PastPaperDocType, ...prev]); + }, []); + + const pastPapersAfterFilter = useMemo(() => { + const filteredRes = [...userUploads, ...pastPapers] + .filter((res, idx, self) => { + return idx === self.findIndex((item) => item.uid === res.uid); + }) + .filter((ele) => ele.deleted === false) + .sort((a, b) => { + const aDate = fromFirebaseTimeStamp(a.upload_at); + const bDate = fromFirebaseTimeStamp(b.upload_at); + return bDate.getTime() - aDate.getTime(); + }); + + return filteredRes; + }, [pastPapers, userUploads]); + + return [ + pastPapersAfterFilter, + { + fetchMore, + validate + } + ]; +}; diff --git a/src/lib/pastpaper/types.ts b/src/lib/pastpaper/types.ts index a314d81..3431898 100644 --- a/src/lib/pastpaper/types.ts +++ b/src/lib/pastpaper/types.ts @@ -6,19 +6,36 @@ export interface PastPaperDocType { photo_url: string; subject_name: string; + exam_type: string; + + // if false user don't want to show it's profile pic + visibility: boolean; + upload_at: FieldValue; - deleted?: boolean; + + // is deleted by user + // not set this field to true if going to mark this field as spam + deleted: boolean; // true if, marked as a spam by moderator - spam?: boolean; + spam: boolean; // list of people uid's those post vote up_votes?: Array; down_votes?: Array; + // votes can be in +ve and -ve votes_count: number; // embedded user inside in the document to prevent joins uploader: Pick; uploader_uid: string; + + // how much confidence AI has that image is correct + confidence: number; + + // if locked user will not able to make changes to post anymore + // will be locked by moderator | + // automatically lock if up votes are up to 5 and percentage of down votes are less then 50% + isLocked: boolean; } diff --git a/src/lib/pastpaper/upload.ts b/src/lib/pastpaper/upload.ts index f8d9882..24e2712 100644 --- a/src/lib/pastpaper/upload.ts +++ b/src/lib/pastpaper/upload.ts @@ -8,13 +8,21 @@ interface UploadProps { file: File | null; currUser: UserDocType; subject_name: string; + confidence: number; + examType: string; + visibility: boolean; } -export default async function upload({ file, subject_name, currUser }: UploadProps) { +export default async function upload({ + file, + subject_name, + currUser, + confidence, + examType, + visibility +}: UploadProps) { if (!file) return; - // todo: validate file input via gemini OR may be try some free otpion - try { const photo_url = await uploadBlobToFirestore(fileToBlob(file)); @@ -22,7 +30,9 @@ export default async function upload({ file, subject_name, currUser }: UploadPro const docData: PastPaperDocType = { photo_url, uid: docRef.id, + exam_type: examType, subject_name, + visibility, upload_at: serverTimestamp(), uploader: { displayName: currUser.displayName, @@ -30,7 +40,11 @@ export default async function upload({ file, subject_name, currUser }: UploadPro uid: currUser.uid }, votes_count: 0, - uploader_uid: currUser.uid + uploader_uid: currUser.uid, + confidence, + isLocked: false, + deleted: false, + spam: false }; await setDoc(docRef, docData); diff --git a/src/lib/util.ts b/src/lib/util.ts index 452b905..10e7575 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -205,7 +205,7 @@ export function getBusyRoomsAlongMetaData(timetables: Array, c : null ) .filter((entry): entry is { room: string; program: string } => entry !== null); - + const busyRooms = timetables .map((timetable) => Object.entries(timetable.timetable) @@ -216,7 +216,7 @@ export function getBusyRoomsAlongMetaData(timetables: Array, c ) .flat(1); - return busyRooms.filter((ele, idx, self)=> idx === self.findIndex(t => t.room === ele.room)); + return busyRooms.filter((ele, idx, self) => idx === self.findIndex((t) => t.room === ele.room)); } /** @@ -253,13 +253,11 @@ export function calculateFreeClassrooms( /** * returns all departments - * @param timetables - * @returns + * @param timetables + * @returns */ -export function getDepartments ( - timetables: Array, -) { - return Array.from(new Set(timetables.map(timetable=> timetable.payload?.program as string))); +export function getDepartments(timetables: Array) { + return Array.from(new Set(timetables.map((timetable) => timetable.payload?.program as string))); } /** @@ -270,14 +268,17 @@ export function getDepartments ( */ export function calculateTimeActivities(timetables: Array, currTime: Date) { const busyRoomsMetadata = getBusyRoomsAlongMetaData(timetables, currTime); - const freeRooms = getFreeRooms(timetables, getBusyRooms(timetables, currTime)).map(room => ({ - room: room, program: undefined + const freeRooms = getFreeRooms(timetables, getBusyRooms(timetables, currTime)).map((room) => ({ + room: room, + program: undefined })); return [...busyRoomsMetadata, ...freeRooms]; } export function fromFirebaseTimeStamp(time: any): Date { + if (!time || time.seconds === undefined || time.nanoseconds === undefined) return new Date(); + const fireBaseTime = new Date(time.seconds * 1000 + time.nanoseconds / 1000000); return fireBaseTime; } @@ -468,3 +469,15 @@ export function fileToBlob(file: File): Blob { const blobFile = new Blob([file], { type: file.type }); return blobFile; } + +export const dataURLtoFile = (dataurl: string, filename: string): File => { + const arr = dataurl.split(','); + const mime = (arr[0] || '').match(/:(.*?);/)?.at(1); + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, { type: mime }); +}; diff --git a/yarn.lock b/yarn.lock index e96f096..5380589 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4674,6 +4674,11 @@ react-use-scroll-position@^2.0.0: resolved "https://registry.yarnpkg.com/react-use-scroll-position/-/react-use-scroll-position-2.0.0.tgz#c5bb29f751d126ff1c0306c28d7055ce0d4ef034" integrity sha512-nQaWMPElzI8aN1vYTnqy0xV17pEhItgyWUBOtdmMEqsbY1mV3+iZMyTeAJ+oegsQIugYxBU4LnV2NosynN+KKA== +react-webcam@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/react-webcam/-/react-webcam-7.2.0.tgz#64141c4c7bdd3e956620500187fa3fcc77e1fd49" + integrity sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg== + react@18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"