diff --git a/backend-services/feed-service/app/controllers/QuestionController.scala b/backend-services/feed-service/app/controllers/QuestionController.scala index 21cfda6c34180426729a329d29ad9be7cf34b666..9cb8c1c0b5cd5b50ac300046db7a175069270c57 100644 --- a/backend-services/feed-service/app/controllers/QuestionController.scala +++ b/backend-services/feed-service/app/controllers/QuestionController.scala @@ -2,7 +2,7 @@ package controllers import models.{Question} import models.actions.{AuthenticatedUserAction, AuthenticationRequest} -import models.exceptions.{ConflictException, InvalidRequestBodyException, ForbiddenException} +import models.exceptions.{ConflictException, InvalidRequestBodyException, InvalidQueryParameterException, ForbiddenException} import javax.inject._ import play.api.mvc._ @@ -10,6 +10,7 @@ import play.api.libs.json.{JsValue} import play.api.libs.json.JsLookupResult import scala.concurrent.TimeoutException +import org.bson.types.ObjectId /** @@ -19,6 +20,27 @@ import scala.concurrent.TimeoutException class QuestionController @Inject()(val controllerComponents: ControllerComponents, authenticatedUserAction: AuthenticatedUserAction) extends BaseController { + /** + * Create an Action to get all the Questions in the DB. + */ + def getQuestions() = authenticatedUserAction { implicit request: AuthenticationRequest[AnyContent] => + println("QuestionController:getQuestions") + + try { + if (!request.isAdmin) throw new ForbiddenException("You must be an admin to get the stored questions.") + val questions: Seq[Question] = Question.getQuestionsAsync() + val jsonResult: JsValue = Question.toJson(questions) + Ok(jsonResult) + } catch { + case _: TimeoutException => BadRequest("Request timed out") + case ex: ForbiddenException => Forbidden(ex.getMessage()) + case ex: Throwable => { + println(ex.getMessage()) + BadRequest("Exception raised") + } + } + } + /** * Create an Action to insert a Question to the DB. */ @@ -41,6 +63,30 @@ class QuestionController @Inject()(val controllerComponents: ControllerComponent } } + /** + * Create an Action to disable a Question in the DB. + */ + def disableQuestion(questionId: String) = authenticatedUserAction { implicit request: AuthenticationRequest[AnyContent] => + println("QuestionController:disableQuestion") + + try { + if (!ObjectId.isValid(questionId)) throw new InvalidQueryParameterException("Invalid query parameter ID format: questionId") + if (!request.isAdmin) throw new ForbiddenException("You must be an admin to disable a question.") + + Question.disableQuestionAsync(new ObjectId(questionId)) + Ok("Disabled question") + } catch { + case _: TimeoutException => BadRequest("Request timed out") + case ex: InvalidQueryParameterException => BadRequest(ex.getMessage()) + case ex: ConflictException => BadRequest(ex.getMessage()) + case ex: ForbiddenException => Forbidden(ex.getMessage()) + case ex: Throwable => { + println(ex.getMessage()) + BadRequest("Exception raised") + } + } + } + /** * Fetch the needed values from the request body for the liking/unliking a Daily endpoint. * diff --git a/backend-services/feed-service/app/models/Question.scala b/backend-services/feed-service/app/models/Question.scala index dd887862ad3f916bc709262ad7eaca2ada230be2..e4ea5254fe7b9767f9476cadb7bd11ab76e6a2ca 100644 --- a/backend-services/feed-service/app/models/Question.scala +++ b/backend-services/feed-service/app/models/Question.scala @@ -17,11 +17,16 @@ import org.bson.conversions.Bson import org.mongodb.scala.model.{Filters, Updates, Sorts} +import play.api.libs.json.{Json, JsValue, JsString, JsObject, JsNumber, JsBoolean, JsArray} +import scala.math.BigDecimal +import java.text.SimpleDateFormat + case class Question ( id: Option[ObjectId], content: String, used: Integer, + disabled: Boolean, createdAt: Date, updatedAt: Date ) @@ -29,9 +34,14 @@ case class Question ( object Question { val questionRepo = new QuestionRepository() + def getQuestionsAsync(timeout: Int = 4): Seq[Question] = { + val result: Future[Seq[Question]] = questionRepo.getAll(Some(Filters.eq("disabled", false)), Some(Sorts.descending("createdAt")), None, None) + Await.result[Seq[Question]](result, timeout.seconds) + } + def createQuestionAsync(content: String, timeout: Int = 4): Question = { val now: Date = Date.from(Instant.now()) - val question: Question = Question(None, content, 0, now, now) + val question: Question = Question(None, content, 0, false, now, now) val newQuestion = for { questionExists <- questionRepo.getAll(Some(Filters.eq("content", question.content)), None, None, None).map(_.isEmpty) @@ -42,6 +52,13 @@ object Question { Await.result[Question](newQuestion, timeout.seconds) } + def disableQuestionAsync(questionId: ObjectId, timeout: Int = 4): Unit = { + val update: Bson = Updates.set("disabled", true) + val disable: Future[Unit] = questionRepo.updateOne(questionId, Seq(update)) + + Await.result[Unit](disable, timeout.seconds) + } + def getLeastUsed(): Future[Question] = { questionRepo.getFirst(None, Some(Sorts.ascending("used")), None).map((question: Option[Question]) => { if (question.isEmpty) @@ -56,6 +73,31 @@ object Question { questionRepo.updateOne(question.id.get, Seq(update)) } + // Convert from Question object to JSON (serializing to JSON) + def toJson(question: Question): JsValue = { + val dateFormat: SimpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + val formattedCreatedAt: String = dateFormat.format(question.createdAt) + val formattedUpdatedAt: String = dateFormat.format(question.updatedAt) + + val questionJson = Seq( + "id" -> JsString(question.id.getOrElse("").toString()), + "content" -> JsString(question.content), + "used" -> JsNumber(BigDecimal(question.used)), + "disabled" -> JsBoolean(question.disabled), + "createdAt" -> JsString(formattedCreatedAt), + "updatedAt" -> JsString(formattedUpdatedAt) + ) + + Json.toJson[JsObject](JsObject(questionJson)) + } + + // Convert from Question set to JSON (serializing to JSON) + def toJson(questions: Seq[Question]): JsValue = { + val questionsJson: Seq[JsValue] = questions.map(question => Question.toJson(question)) + + Json.toJson[JsArray](JsArray(questionsJson)) + } + // Codec instance for serialising/deserialising type Question to or from BSON. // Implicit keyword lets Scala compiler automatically insert this into the code where it's needed. implicit val codec: Codec[Question] = new Codec[Question] { @@ -63,6 +105,7 @@ object Question { writer.writeStartDocument() writer.writeString("content", value.content) writer.writeInt32("used", value.used) + writer.writeBoolean("disabled", value.disabled) writer.writeDateTime("createdAt", value.createdAt.getTime()) writer.writeDateTime("updatedAt", value.updatedAt.getTime()) writer.writeEndDocument() @@ -73,6 +116,7 @@ object Question { val id = reader.readObjectId("_id") val content = reader.readString("content") val used = reader.readInt32("used") + val disabled = reader.readBoolean("disabled") val createdAt = reader.readDateTime("createdAt") val updatedAt = reader.readDateTime("updatedAt") reader.readEndDocument() @@ -80,7 +124,7 @@ object Question { val createdAtDate: Date = Date.from(Instant.ofEpochMilli(createdAt)) val updatedAtDate: Date = Date.from(Instant.ofEpochMilli(updatedAt)) - Question(Some(id), content, used, createdAtDate, updatedAtDate) + Question(Some(id), content, used, disabled, createdAtDate, updatedAtDate) } override def getEncoderClass: Class[Question] = classOf[Question] diff --git a/backend-services/feed-service/app/repositories/Repository.scala b/backend-services/feed-service/app/repositories/Repository.scala index b6eacb9b1a39133a8faa6d5096c319015bc0c2f8..43ea68e225574f1ebe602f7edf77e8e6e437d43c 100644 --- a/backend-services/feed-service/app/repositories/Repository.scala +++ b/backend-services/feed-service/app/repositories/Repository.scala @@ -126,4 +126,13 @@ class Repository[T: ClassTag](databaseName: String, collectionName: String) { def updateOne(documentId: ObjectId, updates: Seq[Bson]): Future[Unit] = { MongoConnection.updateOne[T](collection, documentId, updates) } + + /** + * Delete one document from the collection that matches the given ID. + * + * @param documentId The ID of the document to delete. + */ + def deleteOne(documentId: ObjectId): Future[Unit] = { + MongoConnection.deleteOne(collection, documentId) + } } diff --git a/backend-services/feed-service/app/utils/MongoConnection.scala b/backend-services/feed-service/app/utils/MongoConnection.scala index 6355414a44c28bcfccbcfc26ca97911173d9072b..9b4366e1b6d20b2daf7fcde393e0e622d412b456 100644 --- a/backend-services/feed-service/app/utils/MongoConnection.scala +++ b/backend-services/feed-service/app/utils/MongoConnection.scala @@ -2,7 +2,7 @@ package utils import org.mongodb.scala.{MongoClient, MongoDatabase, MongoCollection} import org.mongodb.scala.model.{Filters} -import com.mongodb.client.result.{InsertOneResult, UpdateResult} +import com.mongodb.client.result.{InsertOneResult, UpdateResult, DeleteResult} import org.bson.conversions.Bson import org.bson.types.ObjectId @@ -113,8 +113,38 @@ object MongoConnection { val futureResult: Future[UpdateResult] = collection.updateOne(filter, updates).toFuture() futureResult.map[Unit]((result: UpdateResult) => { + if (result.getMatchedCount == 0) { + throw new RuntimeException("No document with the given ID.") + } + if (!result.wasAcknowledged()) { - throw new RuntimeException("Update was not acknowledged") + throw new RuntimeException("Update was not acknowledged.") + } + + if (result.getModifiedCount() == 0) { + throw new RuntimeException("No document was modified.") + } + }) + } + + /** + * Delete one document from the collection that matches the given ID. + * + * @param collection The MongoCollection instance the document is in. + * @param documentId The ID of the document to delete. + * @throws RuntimeException if the delete was not acknowledged by the database. + */ + def deleteOne[T](collection: MongoCollection[T], documentId: ObjectId): Future[Unit] = { + val filter: Bson = Filters.equal[ObjectId]("_id", documentId) + val futureResult: Future[DeleteResult] = collection.deleteOne(filter).toFuture() + + futureResult.map[Unit]((result: DeleteResult) => { + if (!result.wasAcknowledged()) { + throw new RuntimeException("Delete was not acknowledged.") + } + + if (result.getDeletedCount() == 0) { + throw new RuntimeException("No document was deleted.") } }) } diff --git a/backend-services/feed-service/conf/routes b/backend-services/feed-service/conf/routes index 9fb7c23c95c098264bbf98c56c5db5f301389b6f..e8e358f46ce4e3fbfa7c553bdc99e431713c9710 100644 --- a/backend-services/feed-service/conf/routes +++ b/backend-services/feed-service/conf/routes @@ -29,4 +29,8 @@ GET /test/verifyUser controllers.TestController.verifyUser(userId: St GET /question controllers.DailyQuestionController.getDailyQuestion() -POST /question controllers.QuestionController.insertQuestion() +GET /questions controllers.QuestionController.getQuestions() + +POST /insertQuestion controllers.QuestionController.insertQuestion() + +POST /disableQuestion controllers.QuestionController.disableQuestion(questionId: String) diff --git a/daily-thought-frontend/src/components/navigation/NavBar.tsx b/daily-thought-frontend/src/components/navigation/NavBar.tsx index 8cf60d265a205aab84851b36b8a2f16cdcd8c298..c9ad4d4ca284e3c7e7db41f661e1e66f907474e6 100644 --- a/daily-thought-frontend/src/components/navigation/NavBar.tsx +++ b/daily-thought-frontend/src/components/navigation/NavBar.tsx @@ -1,6 +1,6 @@ import { FC, PropsWithChildren } from 'react' import { Disclosure, Menu } from '@headlessui/react' -import { Bars3Icon, BellIcon, XMarkIcon } from '@heroicons/react/24/outline' +import { Bars3Icon, BellIcon, XMarkIcon, } from '@heroicons/react/24/outline' import { UserCircleIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid' import { User } from '@/types/user' import NavMenu from './NavMenu' @@ -12,11 +12,18 @@ import Router from 'next/router' const navigation = [ { name: 'My Feed', href: '/feed', current: true } ] -const userNavigation = [ - { name: 'Your Profile', href: '/profile' }, - { name: 'Friends', href: '/search'}, - { name: 'Sign out', href: '/signOut' }, -] + +const userNavigation = (isAdmin: Boolean) => { + const list = [ + { name: 'Your Profile', href: '/profile' }, + { name: 'Friends', href: '/search' } + ]; + + isAdmin && list.push({ name: 'Questions', href: '/questions' }); + list.push({ name: 'Sign out', href: '/signOut' }); + return list; +}; + function classNames(...classes: any) { return classes.filter(Boolean).join(' ') @@ -98,7 +105,7 @@ const NavBar: FC<PropsWithChildren<NavBarProps>> = ({ <div className='w-full border-b'> <p className='block px-4 py-2 text-sm text-gray-700 font-bold'>{`Hi, ${user?.firstName || user?.username}`}</p> </div> - {userNavigation.map((item) => ( + {userNavigation(user.admin).map((item) => ( <Menu.Item key={item.name}> {({ active }) => ( <a @@ -180,7 +187,7 @@ const NavBar: FC<PropsWithChildren<NavBarProps>> = ({ </button> </div> <div className="mt-4 space-y-1 px-2"> - {userNavigation.map((item) => ( + {userNavigation(user?.admin ?? false).map((item) => ( <Disclosure.Button key={item.name} as="a" diff --git a/daily-thought-frontend/src/components/questions/NewQuestionForm.tsx b/daily-thought-frontend/src/components/questions/NewQuestionForm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a5dff79f30aad89ac717860b19df6895faeb03e --- /dev/null +++ b/daily-thought-frontend/src/components/questions/NewQuestionForm.tsx @@ -0,0 +1,73 @@ +import { Dispatch, FC, FormEvent, SetStateAction } from 'react'; +import { StatusMessage } from '@/types/statusMessage'; + +type NewQuestionFormProps = { + newQuestion: string; + setNewQuestion: Dispatch<SetStateAction<string>>; + onSubmit: () => Promise<void>; + statusMessage: StatusMessage | undefined; +}; + +const NewQuestionForm: FC<NewQuestionFormProps> = ({ + newQuestion, + setNewQuestion, + onSubmit, + statusMessage +}) => { + // Set the colour of the status message + const statusTextColour = statusMessage + ? statusMessage.error + ? 'text-red-500' + : 'text-green-500' + : ''; + + const onFormSubmit = (e: FormEvent<HTMLFormElement>): void => { + e.preventDefault(); + onSubmit(); + }; + + return ( + <div className="flex flex-col items-center align-center py-3 pt-20 shadow-md w-full shrink-0 grow-0"> + {/* Title */} + <h2 className="text-xl font-semibold leading-7 text-gray-900">New Question</h2> + + {/* Description */} + <p className="mt-1 text-sm leading-6 text-gray-600 text-center"> + As an admin, you can input your own question for the system to choose from. + </p> + + {/* Question Form */} + <form className="flex flex-col align-center items-center pt-6 w-4/5 md:3/5" onSubmit={onFormSubmit}> + {/* Text Input */} + <input + type="text" + name="new-question" + id="new-question" + placeholder="A new exciting question..." + className="block rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 + ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset + focus:ring-indigo-600 sm:text-sm sm:leading-6 w-80 md:w-3/5 lg:w-1/2" + value={newQuestion} + onChange={(e) => setNewQuestion(e.target.value)} + /> + + {/* Status Message */} + {statusMessage && ( + <p className={'mt-1 text-sm leading-6 text-center ' + statusTextColour}> + {statusMessage.message} + </p> + )} + + {/* Submit Button */} + <button + type="submit" + className="rounded-md bg-c-pink px-3 py-2 mt-6 text-sm font-semibold text-white shadow-sm hover:bg-c-green focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + Insert + </button> + </form> + </div> + ); +}; + +export default NewQuestionForm; diff --git a/daily-thought-frontend/src/components/questions/QuestionList.tsx b/daily-thought-frontend/src/components/questions/QuestionList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0bdbe12337823c1f58b875dd0c210385fd7b6f08 --- /dev/null +++ b/daily-thought-frontend/src/components/questions/QuestionList.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react'; +import { QuestionRecord } from '@/hooks/useQuestions'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import styles from '../../styles/Scrollbox.module.css'; + +type QuestionListProps = { + questions: QuestionRecord[]; + onDeleteClick: (questionId: string) => void; +}; + +const QuestionList: FC<QuestionListProps> = ({ questions, onDeleteClick }) => ( + <ul + role="list" + className={`flex flex-col min-h-min max-w-lg overflow-y-scroll divide-y divide-gray-100 mt-4 min-w-[16rem] sm:min-w-[24rem] ${styles['scrollbox-shadows']}`} + > + {questions.map((question: QuestionRecord) => ( + <li + key={question.id} + className="flex flex-row items-center justify-between gap-x-6 py-5 px-4" + > + <p className="text-base font leading-6 text-gray-900">{question.content}</p> + <TrashIcon className="shrink-0 h-4 w-4 cursor-pointer" color='grey' type='button' onClick={() => onDeleteClick(question.id)} /> + </li> + ))} + </ul> +); + +export default QuestionList; diff --git a/daily-thought-frontend/src/hooks/useQuestions.ts b/daily-thought-frontend/src/hooks/useQuestions.ts new file mode 100644 index 0000000000000000000000000000000000000000..de67e315fb03ec2859e9db40a94ef0c42831e491 --- /dev/null +++ b/daily-thought-frontend/src/hooks/useQuestions.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; + +export type QuestionRecord = { + id: string; + content: string; + used: number; + createdAt: string; + updatedAt: string; +}; + +export const useQuestions = () => { + const [rehydrateQuestions, setRehydrateQuestions] = useState(false); + const [questions, setQuestions] = useState<undefined | QuestionRecord[]>(undefined); + + useEffect(() => { + if (questions !== undefined) return; + setRehydrateQuestions(true); + }, [questions]); + + const fetchQuestions = async () => { + const endpoint = `${process.env.NEXT_PUBLIC_FEED_SERVICE_URL}questions`; + const headers = { Authorization: `Bearer ${sessionStorage.getItem('token')}` }; + + const response = await fetch(endpoint, { headers }); + if (response.ok) { + const data = await response.json(); + return data; + } + }; + + useEffect(() => { + if (!rehydrateQuestions) return; + + fetchQuestions().then((res: QuestionRecord[]) => { + setQuestions(res); + setRehydrateQuestions(false); + }); + }, [rehydrateQuestions]); + + return { questions, setRehydrateQuestions }; +}; diff --git a/daily-thought-frontend/src/hooks/useUser.ts b/daily-thought-frontend/src/hooks/useUser.ts index 78d3cf7ab47dcb936eabbd3ccb3d1cae202b9f5b..489f757f869f94e2d215473a20e677285f70eaff 100644 --- a/daily-thought-frontend/src/hooks/useUser.ts +++ b/daily-thought-frontend/src/hooks/useUser.ts @@ -29,8 +29,8 @@ export const useUser = () => { if (!rehydrateUser) return; fetchUser().then((res) => { - const { _id, username, email, profile, firstName, lastName } = res; - setUser({ id: _id, email, username, profile, firstName, lastName }); + const { _id, username, email, profile, firstName, lastName, admin } = res; + setUser({ id: _id, email, username, profile, firstName, lastName, admin }); setRehydrateUser(false); }); }, [rehydrateUser]); diff --git a/daily-thought-frontend/src/pages/feed.tsx b/daily-thought-frontend/src/pages/feed.tsx index 5307b2d4287a2f779d5b3b709ccd14efe05a6c21..b3958b77360c338eb6d8e1aee0e7e86f5dec284c 100644 --- a/daily-thought-frontend/src/pages/feed.tsx +++ b/daily-thought-frontend/src/pages/feed.tsx @@ -106,6 +106,7 @@ const Feed = () => { if (feedUser !== undefined && user !== undefined) { return ( <Post + key={feedPost.id} post={feedPost} user={feedUser} question={question.question} diff --git a/daily-thought-frontend/src/pages/profile.tsx b/daily-thought-frontend/src/pages/profile.tsx index 5bbf95db078c06f6c1c4d18bf52cc556210c34fc..bfee2b87214e8691231843809516310034534058 100644 --- a/daily-thought-frontend/src/pages/profile.tsx +++ b/daily-thought-frontend/src/pages/profile.tsx @@ -18,8 +18,8 @@ const Profile = () => { useEffect(() => { if (!user) { fetchUser().then((res) => { - const { _id, username, email, profile, firstName, lastName } = res; - setUser({ id: _id, email, username, profile, firstName, lastName }); + const { _id, username, email, profile, firstName, lastName, admin } = res; + setUser({ id: _id, email, username, profile, firstName, lastName, admin }); }); } }); diff --git a/daily-thought-frontend/src/pages/questions.tsx b/daily-thought-frontend/src/pages/questions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff3e3662f5e0d36cdf1842a2c5456c8778851d96 --- /dev/null +++ b/daily-thought-frontend/src/pages/questions.tsx @@ -0,0 +1,111 @@ +import NavBar from '@/components/navigation/NavBar'; +import { useQuestions } from '@/hooks/useQuestions'; +import { useUser } from '@/hooks/useUser'; +import { useState, useEffect } from 'react'; +import Router from 'next/router'; +import NewQuestionForm from '@/components/questions/NewQuestionForm'; +import QuestionList from '@/components/questions/QuestionList'; +import { StatusMessage } from '@/types/statusMessage'; + +const Questions = () => { + const { user } = useUser(); + const { questions, setRehydrateQuestions } = useQuestions(); + const [newQuestion, setNewQuestion] = useState(''); + const [statusMessage, setStatusMessage] = useState<StatusMessage | undefined>(undefined); + + // Redirect user from page if not an admin + useEffect(() => { + if (user?.admin === false) { + Router.push('/feed'); + } + }, [user]); + + const insertNewQuestion = async (newQuestion: String): Promise<void> => { + const JSONdata = JSON.stringify({ questionText: newQuestion }); + const endpoint = `${process.env.NEXT_PUBLIC_FEED_SERVICE_URL}insertQuestion`; + const options = { + method: 'POST', + headers: { + Authorization: `Bearer ${sessionStorage.getItem('token')}`, + 'Content-Type': 'application/json' + }, + body: JSONdata + }; + + var response = await fetch(endpoint, options); + var message = await response.text(); + + switch (response.status) { + case 201: + setStatusMessage({ + message: 'Question was successfully inserted!', + error: false + } as StatusMessage); + break; + case 400: + setStatusMessage({ message: message, error: true } as StatusMessage); + break; + default: + setStatusMessage({ + message: 'Unexpected error, please try again.', + error: true + } as StatusMessage); + break; + } + }; + + const deleteQuestion = async (questionId: String): Promise<void> => { + const endpoint = `${process.env.NEXT_PUBLIC_FEED_SERVICE_URL}disableQuestion?questionId=${questionId}`; + const options = { + method: 'POST', + headers: { + Authorization: `Bearer ${sessionStorage.getItem('token')}`, + 'Content-Type': 'application/json' + } + }; + + await fetch(endpoint, options); + }; + + const onQuestionInsert = async (): Promise<void> => { + await insertNewQuestion(newQuestion); + setNewQuestion(''); + setRehydrateQuestions(true); + }; + + const onQuestionDelete = async (questionId: string): Promise<void> => { + await deleteQuestion(questionId); + setStatusMessage(undefined); + setRehydrateQuestions(true); + }; + + return ( + <div className="flex flex-col w-full items-center h-screen"> + {/* Navigation Bar */} + <div className="w-full"> + <NavBar user={user} /> + </div> + + {/* Question Insert */} + <NewQuestionForm + newQuestion={newQuestion} + setNewQuestion={setNewQuestion} + onSubmit={onQuestionInsert} + statusMessage={statusMessage} + /> + + {/* List of Questions */} + <div className="flex flex-col items-center w-full shadow-lg h-full pb-4 overflow-auto"> + <h2 className="bg-gray-50 text-xl font-semibold leading-10 py-2 text-gray-900 w-full text-center">Questions Stored</h2> + + {questions === undefined ? ( + <p>Loading...</p> + ) : ( + <QuestionList questions={questions} onDeleteClick={onQuestionDelete} /> + )} + </div> + </div> + ); +}; + +export default Questions; diff --git a/daily-thought-frontend/src/styles/Scrollbox.module.css b/daily-thought-frontend/src/styles/Scrollbox.module.css new file mode 100644 index 0000000000000000000000000000000000000000..26c6b89221e7cd5b2d714f602fd19c829f36159b --- /dev/null +++ b/daily-thought-frontend/src/styles/Scrollbox.module.css @@ -0,0 +1,9 @@ +.scrollbox-shadows { + background: /* Shadow covers */ + linear-gradient(white 30%, rgba(255, 255, 255, 0)), linear-gradient(rgba(255, 255, 255, 0), white 70%) 0 100%, /* Shadows */ + radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, .2), rgba(0, 0, 0, 0)), radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, .2), rgba(0, 0, 0, 0)) 0 100%; + background-repeat: no-repeat; + background-color: white; + background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px; + background-attachment: local, local, scroll, scroll; +} diff --git a/daily-thought-frontend/src/types/statusMessage.ts b/daily-thought-frontend/src/types/statusMessage.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdb5a5244ce8f4bc45f75a4b4a4bcb458bf90f21 --- /dev/null +++ b/daily-thought-frontend/src/types/statusMessage.ts @@ -0,0 +1,4 @@ +export type StatusMessage = { + message: string; + error: boolean; +}; diff --git a/daily-thought-frontend/src/types/user.ts b/daily-thought-frontend/src/types/user.ts index 4a25fbc4f122991e4b9d154e755474db368abc7b..f20e500d66a2e5542bc0d10d01ab7c90295c3f02 100644 --- a/daily-thought-frontend/src/types/user.ts +++ b/daily-thought-frontend/src/types/user.ts @@ -4,5 +4,6 @@ export type User = { username: string, id: string, firstName?: string, - lastName?: string + lastName?: string, + admin: boolean, } \ No newline at end of file