From 46ca8327631ca3529ec4576f13ac60d853a4a90d Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann <103655828+emiliosheinz@users.noreply.github.com> Date: Sat, 21 Jan 2023 10:30:08 -0300 Subject: [PATCH 1/8] feat: :sparkles: adds four oh four page --- public/images/not-found-illustration.svg | 1 + src/pages/404.tsx | 33 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 public/images/not-found-illustration.svg create mode 100644 src/pages/404.tsx diff --git a/public/images/not-found-illustration.svg b/public/images/not-found-illustration.svg new file mode 100644 index 0000000..ad3785c --- /dev/null +++ b/public/images/not-found-illustration.svg @@ -0,0 +1 @@ + diff --git a/src/pages/404.tsx b/src/pages/404.tsx new file mode 100644 index 0000000..f9e0811 --- /dev/null +++ b/src/pages/404.tsx @@ -0,0 +1,33 @@ +import { useRouter } from 'next/router' + +import { Button } from '~/components/button' +import { Image } from '~/components/image' +import { routes } from '~/utils/navigation' + +export default function FourOhFor() { + const router = useRouter() + + return ( +
+
+

Desculpe,

+

+ não foi possível encontrar esta página. +

+
+ 404 illustration + +
+ ) +} From 2b49bc72e40ea28c1e7e07a31d408f744c770d58 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann <103655828+emiliosheinz@users.noreply.github.com> Date: Sat, 21 Jan 2023 11:42:57 -0300 Subject: [PATCH 2/8] feat: :sparkles: adds five hundred error page --- public/images/lost-in-space.svg | 1 + src/pages/500.tsx | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 public/images/lost-in-space.svg create mode 100644 src/pages/500.tsx diff --git a/public/images/lost-in-space.svg b/public/images/lost-in-space.svg new file mode 100644 index 0000000..6dfc88d --- /dev/null +++ b/public/images/lost-in-space.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pages/500.tsx b/src/pages/500.tsx new file mode 100644 index 0000000..19367b7 --- /dev/null +++ b/src/pages/500.tsx @@ -0,0 +1,35 @@ +import { useRouter } from 'next/router' + +import { Button } from '~/components/button' +import { Image } from '~/components/image' +import { routes } from '~/utils/navigation' + +export default function FiveHundred() { + const router = useRouter() + + return ( +
+
+

+ Erro inesperado! +

+

+ Desculpe, parece que houve um erro inesperado em nosso site. +

+
+ 404 illustration + +
+ ) +} From d3b3c3c4c6f597cca16ec8faa19ba60d405e8951 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann <103655828+emiliosheinz@users.noreply.github.com> Date: Sat, 21 Jan 2023 17:41:23 -0300 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E2=9C=A8=20adds=20deck=20details?= =?UTF-8?q?=20screen=20with=20deck=20edit=20and=20dellete=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: :sparkles: adds deck details route * feat: :sparkles: adds server side logic for deck details screen * refactor: :recycle: adds not found as error * feat: :sparkles: adds deck details screen with deck edit and delet options --- src/components/card/card.component.tsx | 9 +- src/components/card/card.types.ts | 3 + .../deck-card-list.component.tsx | 2 +- .../create-new-deck.context.tsx | 6 +- src/pages/decks/[id].tsx | 254 ++++++++++++++++++ src/pages/decks/create/[id].tsx | 16 +- src/server/api/routers/decks/decks.router.ts | 9 +- src/utils/api/index.ts | 12 +- src/utils/navigation/index.ts | 1 + 9 files changed, 288 insertions(+), 24 deletions(-) create mode 100644 src/pages/decks/[id].tsx diff --git a/src/components/card/card.component.tsx b/src/components/card/card.component.tsx index f4c79f2..936a194 100644 --- a/src/components/card/card.component.tsx +++ b/src/components/card/card.component.tsx @@ -7,7 +7,8 @@ import { classNames } from '~/utils/css' import type { CardProps } from './card.types' export function _Card(props: CardProps) { - const { children, isEditable, onDeletePress, onEditPress, onClick } = props + const { children, isEditable, onDeletePress, onEditPress, onClick, as } = + props const renderEditButtons = () => { if (!isEditable) return null @@ -24,8 +25,10 @@ export function _Card(props: CardProps) { ) } + const Container = as || 'div' + return ( -
{children} {renderEditButtons()} -
+ ) } diff --git a/src/components/card/card.types.ts b/src/components/card/card.types.ts index b10193c..18b98f1 100644 --- a/src/components/card/card.types.ts +++ b/src/components/card/card.types.ts @@ -1,7 +1,10 @@ +import type { ReactTag } from '@headlessui/react/dist/types' + export type CardProps = { children: React.ReactNode isEditable?: boolean onDeletePress?: () => void onEditPress?: () => void onClick?: () => void + as?: ReactTag } diff --git a/src/components/deck-card-list/deck-card-list.component.tsx b/src/components/deck-card-list/deck-card-list.component.tsx index 484073d..985a14f 100644 --- a/src/components/deck-card-list/deck-card-list.component.tsx +++ b/src/components/deck-card-list/deck-card-list.component.tsx @@ -12,7 +12,7 @@ export function DeckCardList({ decks }: DeckCardListProps) {
{decks.map((deck, idx) => ( diff --git a/src/contexts/create-new-deck/create-new-deck.context.tsx b/src/contexts/create-new-deck/create-new-deck.context.tsx index 84f2a4f..ba44fec 100644 --- a/src/contexts/create-new-deck/create-new-deck.context.tsx +++ b/src/contexts/create-new-deck/create-new-deck.context.tsx @@ -153,7 +153,7 @@ export function CreateNewDeckContextProvider( compress(values.image[0] as File), ]) - await createNewDeckMutation.mutateAsync({ + const deck = await createNewDeckMutation.mutateAsync({ ...values, cards, topics, @@ -164,7 +164,7 @@ export function CreateNewDeckContextProvider( await uploadImage(uploadConfig.uploadUrl, image) notify.success('Deck criado com sucesso!') - router.replace(routes.home()) + router.replace(routes.deckDetails(deck.id)) } catch (error) { handleApiClientSideError({ error }) } finally { @@ -225,7 +225,7 @@ export function CreateNewDeckContextProvider( } notify.success('Deck editado com sucesso!') - router.replace(routes.home()) + router.back() } catch (error) { console.log(error) handleApiClientSideError({ error }) diff --git a/src/pages/decks/[id].tsx b/src/pages/decks/[id].tsx new file mode 100644 index 0000000..569a233 --- /dev/null +++ b/src/pages/decks/[id].tsx @@ -0,0 +1,254 @@ +import { Fragment } from 'react' + +import { Menu, Transition } from '@headlessui/react' +import { + PencilSquareIcon, + TrashIcon, + EllipsisVerticalIcon, +} from '@heroicons/react/24/outline' +import { Visibility } from '@prisma/client' +import { useSetAtom } from 'jotai' +import type { GetServerSideProps, InferGetServerSidePropsType } from 'next' +import { type NextPage } from 'next' +import { useSession } from 'next-auth/react' +import dynamic from 'next/dynamic' +import Head from 'next/head' +import { useRouter } from 'next/router' + +import { Card } from '~/components/card' +import { Image } from '~/components/image' +import { getServerAuthSession } from '~/server/common/auth' +import { prisma } from '~/server/common/db' +import { getS3ImageUrl } from '~/server/common/s3' +import { api, handleApiClientSideError } from '~/utils/api' +import { fullScreenLoaderAtom } from '~/utils/atoms' +import { classNames } from '~/utils/css' +import { routes } from '~/utils/navigation' +import { notify } from '~/utils/toast' + +const Pill = dynamic(() => + import('~/components/pill').then(module => module.Pill), +) + +async function getDeckFromDatabase(deckId: string) { + return await prisma.deck.findFirst({ + where: { id: deckId }, + include: { + cards: { select: { id: true, question: true } }, + topics: true, + }, + }) +} + +function buildDeckWithS3Image(deck: DeckQueryResult) { + return { + ...deck, + image: getS3ImageUrl(deck.image), + } +} + +type DeckQueryResult = NonNullable< + Awaited> +> + +export const getServerSideProps: GetServerSideProps<{ + deck: DeckQueryResult +}> = async context => { + const deckId = context.params?.id as string + + if (!deckId) return { notFound: true } + + const deck = await getDeckFromDatabase(deckId) + + if (!deck) return { notFound: true } + + /** + * If deck is not private anyone can access it + */ + if (deck.visibility !== Visibility.Private) { + return { + props: { + deck: buildDeckWithS3Image(deck), + }, + } + } + + const session = await getServerAuthSession(context) + + /** + * Verifies if user is signed in and if is the owner of the deck + */ + if (!session?.user) return { notFound: true } + if (deck.ownerId !== session.user.id) return { notFound: true } + + return { + props: { + deck: buildDeckWithS3Image(deck), + }, + } +} + +function ActionsDropDown({ + className, + deckId, +}: { + className?: string + deckId: string +}) { + const router = useRouter() + const setIsLoading = useSetAtom(fullScreenLoaderAtom) + const deleteDeckMutation = api.decks.deleteDeck.useMutation() + + const actions = [ + { + label: 'Editar Deck', + icon: PencilSquareIcon, + onClick: () => router.push(routes.editDeck(deckId)), + }, + { + label: 'Excluir Deck', + icon: TrashIcon, + onClick: async () => { + try { + setIsLoading(true) + + await deleteDeckMutation.mutateAsync({ id: deckId }) + + notify.success('Deck excluído com sucesso!') + router.back() + } catch (error) { + handleApiClientSideError({ error }) + } finally { + setIsLoading(false) + } + }, + }, + ] + + return ( +
+ +
+ + +
+ + +
+ {actions.map(action => ( + + {({ active }) => ( + + )} + + ))} +
+
+
+
+
+ ) +} + +const DeckDetails: NextPage< + InferGetServerSidePropsType +> = props => { + const { deck } = props + + const { data: session } = useSession() + + const renderTopics = () => { + if (deck.topics.length === 0) return null + + return ( + <> +

Tópicos:

+
    + {deck.topics.map(topic => ( +
  • + {topic.title} +
  • + ))} +
+ + ) + } + + const renderActionButtons = () => { + const isCurrentUserDeckOwner = session?.user?.id === deck.ownerId + + if (!session?.user || !isCurrentUserDeckOwner) return null + + return ( + + ) + } + + return ( + <> + + {deck.title} + + + +
+
+
+ {`${deck.title} +
+
+

+ {deck.title} +

+

+ {deck.description} +

+ {renderTopics()} +
+
+

Cards:

+
    + {deck.cards.map(card => ( + + {card.question} + + ))} +
+ {renderActionButtons()} +
+ + ) +} + +export default DeckDetails diff --git a/src/pages/decks/create/[id].tsx b/src/pages/decks/create/[id].tsx index dbeae88..4b31fac 100644 --- a/src/pages/decks/create/[id].tsx +++ b/src/pages/decks/create/[id].tsx @@ -25,7 +25,6 @@ import { getServerAuthSession } from '~/server/common/auth' import { prisma } from '~/server/common/db' import { getS3ImageUrl } from '~/server/common/s3' import type { WithAuthentication } from '~/types/auth' -import { routes } from '~/utils/navigation' const NEW_DECK_ID = 'new' @@ -42,12 +41,7 @@ export const getServerSideProps: GetServerSideProps<{ const deckId = context.params?.id as string if (!deckId) { - return { - redirect: { - destination: '/', - permanent: false, - }, - } + return { notFound: true } } const session = await getServerAuthSession(context) @@ -183,7 +177,7 @@ const CardsSection = () => { return ( <>

Cards

-
+
{cards.map((card, idx) => ( { return (
- diff --git a/src/server/api/routers/decks/decks.router.ts b/src/server/api/routers/decks/decks.router.ts index ec3157b..8a76933 100644 --- a/src/server/api/routers/decks/decks.router.ts +++ b/src/server/api/routers/decks/decks.router.ts @@ -6,7 +6,7 @@ import { protectedProcedure, publicProcedure, } from '~/server/api/trpc' -import { getS3ImageUrl } from '~/server/common/s3' +import { deleteObjectFromS3, getS3ImageUrl } from '~/server/common/s3' import { DeckInputSchema, UpdateDeckInputSchema } from '~/utils/validators/deck' export const decksRouter = createTRPCRouter({ @@ -73,6 +73,13 @@ export const decksRouter = createTRPCRouter({ }) }, ), + deleteDeck: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input: { id }, ctx }) => { + const deck = await ctx.prisma.deck.findFirstOrThrow({ where: { id } }) + await deleteObjectFromS3(deck.image) + await ctx.prisma.deck.delete({ where: { id } }) + }), getPublicDecks: publicProcedure .input( z.object({ diff --git a/src/utils/api/index.ts b/src/utils/api/index.ts index 579ef50..bdee3fb 100644 --- a/src/utils/api/index.ts +++ b/src/utils/api/index.ts @@ -51,10 +51,16 @@ function isTRPCClientError( } function handleTRPCClientError(error: TRPCClientError) { - const errors = JSON.parse(error.message) + const isArrayOfErrors = error.message.startsWith('[') - for (const { message } of errors) { - notify.error(message) + if (isArrayOfErrors) { + const errors = JSON.parse(error.message) + + for (const { message } of errors) { + notify.error(message) + } + } else { + notify.error(error.message) } } diff --git a/src/utils/navigation/index.ts b/src/utils/navigation/index.ts index c9dd2fb..1f54635 100644 --- a/src/utils/navigation/index.ts +++ b/src/utils/navigation/index.ts @@ -2,4 +2,5 @@ export const routes = { home: () => '/', createNewDeck: () => '/decks/create/new', editDeck: (id: string) => `/decks/create/${id}`, + deckDetails: (id: string) => `/decks/${id}`, } From 17869d32e8e6eefd5a8e61b3f2974b6e3ad0c86b Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sat, 21 Jan 2023 19:48:12 -0300 Subject: [PATCH 4/8] refactor: :lipstick: refactors some style related code --- .../deck-card-list/deck-card-list.component.tsx | 8 +++----- src/components/pill/pill.component.tsx | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/deck-card-list/deck-card-list.component.tsx b/src/components/deck-card-list/deck-card-list.component.tsx index 985a14f..7b0c45d 100644 --- a/src/components/deck-card-list/deck-card-list.component.tsx +++ b/src/components/deck-card-list/deck-card-list.component.tsx @@ -14,7 +14,7 @@ export function DeckCardList({ decks }: DeckCardListProps) {
-
+
{deck.title}
-

- {deck.description} -

+

{deck.description}

))} diff --git a/src/components/pill/pill.component.tsx b/src/components/pill/pill.component.tsx index c12642d..60fb8d9 100644 --- a/src/components/pill/pill.component.tsx +++ b/src/components/pill/pill.component.tsx @@ -17,6 +17,7 @@ export function _Pill(props: PillProps) { className={classNames( 'rounded-full ring-1', isDeletable ? 'items-center justify-center pr-3' : '', + onClick ? '' : 'hover:cursor-default enabled:hover:bg-primary-50', )} variant='secondary' onClick={onClick} From 82ab82e4feaee04e947bb889b041768b2fc56e54 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann <103655828+emiliosheinz@users.noreply.github.com> Date: Sun, 22 Jan 2023 16:41:06 -0300 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E2=9C=A8=20study=20session=20creat?= =?UTF-8?q?ion=20and=20visualization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: :card_file_box: adds study session table * feat: :card_file_box: creates study session box table * feat: :card_file_box: adds study session attemp table * chore: :card_file_box: add studySessionBoxCard to control the box where a card is * feat: :sparkles: adds study session create route * feat: :sparkles: creates study session card and review dates endpoints --- .../migration.sql | 16 +++ .../migration.sql | 24 ++++ .../migration.sql | 34 ++++++ .../migration.sql | 39 ++++++ prisma/schema.prisma | 94 +++++++++++---- src/constants/index.ts | 8 ++ src/pages/decks/[id].tsx | 80 ++++++++++++- src/server/api/root.ts | 2 + src/server/api/routers/study-session/index.ts | 1 + .../study-session/study-session.router.ts | 113 ++++++++++++++++++ src/utils/date-time/index.ts | 17 +++ 11 files changed, 406 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20230122001524_adds_study_session_table/migration.sql create mode 100644 prisma/migrations/20230122004801_creates_study_session_box_table/migration.sql create mode 100644 prisma/migrations/20230122115502_adds_study_session_attempt_and_created_and_updated_ate_fields/migration.sql create mode 100644 prisma/migrations/20230122122528_create_study_session_box_card/migration.sql create mode 100644 src/server/api/routers/study-session/index.ts create mode 100644 src/server/api/routers/study-session/study-session.router.ts create mode 100644 src/utils/date-time/index.ts diff --git a/prisma/migrations/20230122001524_adds_study_session_table/migration.sql b/prisma/migrations/20230122001524_adds_study_session_table/migration.sql new file mode 100644 index 0000000..f0e9992 --- /dev/null +++ b/prisma/migrations/20230122001524_adds_study_session_table/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "StudySession" ( + "userId" TEXT NOT NULL, + "deckId" TEXT NOT NULL, + + CONSTRAINT "StudySession_pkey" PRIMARY KEY ("userId","deckId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "StudySession_userId_deckId_key" ON "StudySession"("userId", "deckId"); + +-- AddForeignKey +ALTER TABLE "StudySession" ADD CONSTRAINT "StudySession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudySession" ADD CONSTRAINT "StudySession_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20230122004801_creates_study_session_box_table/migration.sql b/prisma/migrations/20230122004801_creates_study_session_box_table/migration.sql new file mode 100644 index 0000000..25cc0b8 --- /dev/null +++ b/prisma/migrations/20230122004801_creates_study_session_box_table/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - The primary key for the `StudySession` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The required column `id` was added to the `StudySession` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- AlterTable +ALTER TABLE "StudySession" DROP CONSTRAINT "StudySession_pkey", +ADD COLUMN "id" TEXT NOT NULL, +ADD CONSTRAINT "StudySession_pkey" PRIMARY KEY ("id"); + +-- CreateTable +CREATE TABLE "StudySessionBox" ( + "id" TEXT NOT NULL, + "studySessionId" TEXT NOT NULL, + "lastReview" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewGapInHours" INTEGER NOT NULL, + + CONSTRAINT "StudySessionBox_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "StudySessionBox" ADD CONSTRAINT "StudySessionBox_studySessionId_fkey" FOREIGN KEY ("studySessionId") REFERENCES "StudySession"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20230122115502_adds_study_session_attempt_and_created_and_updated_ate_fields/migration.sql b/prisma/migrations/20230122115502_adds_study_session_attempt_and_created_and_updated_ate_fields/migration.sql new file mode 100644 index 0000000..365af0d --- /dev/null +++ b/prisma/migrations/20230122115502_adds_study_session_attempt_and_created_and_updated_ate_fields/migration.sql @@ -0,0 +1,34 @@ +-- AlterTable +ALTER TABLE "Card" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "StudySession" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "StudySessionBox" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Topic" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateTable +CREATE TABLE "StudySessionAttempt" ( + "id" TEXT NOT NULL, + "answer" TEXT NOT NULL, + "isRight" BOOLEAN NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "studySessionBoxId" TEXT NOT NULL, + "cardId" TEXT NOT NULL, + + CONSTRAINT "StudySessionAttempt_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "StudySessionAttempt" ADD CONSTRAINT "StudySessionAttempt_studySessionBoxId_fkey" FOREIGN KEY ("studySessionBoxId") REFERENCES "StudySessionBox"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudySessionAttempt" ADD CONSTRAINT "StudySessionAttempt_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "Card"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20230122122528_create_study_session_box_card/migration.sql b/prisma/migrations/20230122122528_create_study_session_box_card/migration.sql new file mode 100644 index 0000000..a860b1c --- /dev/null +++ b/prisma/migrations/20230122122528_create_study_session_box_card/migration.sql @@ -0,0 +1,39 @@ +/* + Warnings: + + - You are about to drop the column `cardId` on the `StudySessionAttempt` table. All the data in the column will be lost. + - You are about to drop the column `studySessionBoxId` on the `StudySessionAttempt` table. All the data in the column will be lost. + - Added the required column `studySessionBoxCardId` to the `StudySessionAttempt` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "StudySessionAttempt" DROP CONSTRAINT "StudySessionAttempt_cardId_fkey"; + +-- DropForeignKey +ALTER TABLE "StudySessionAttempt" DROP CONSTRAINT "StudySessionAttempt_studySessionBoxId_fkey"; + +-- AlterTable +ALTER TABLE "StudySessionAttempt" DROP COLUMN "cardId", +DROP COLUMN "studySessionBoxId", +ADD COLUMN "studySessionBoxCardId" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "StudySessionBoxCard" ( + "id" TEXT NOT NULL, + "studySessionBoxId" TEXT NOT NULL, + "cardId" TEXT NOT NULL, + + CONSTRAINT "StudySessionBoxCard_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "StudySessionBoxCard_studySessionBoxId_cardId_key" ON "StudySessionBoxCard"("studySessionBoxId", "cardId"); + +-- AddForeignKey +ALTER TABLE "StudySessionBoxCard" ADD CONSTRAINT "StudySessionBoxCard_studySessionBoxId_fkey" FOREIGN KEY ("studySessionBoxId") REFERENCES "StudySessionBox"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudySessionBoxCard" ADD CONSTRAINT "StudySessionBoxCard_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "Card"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "StudySessionAttempt" ADD CONSTRAINT "StudySessionAttempt_studySessionBoxCardId_fkey" FOREIGN KEY ("studySessionBoxCardId") REFERENCES "StudySessionBoxCard"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3f484e8..e64a8a0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,14 +34,15 @@ model Session { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String? @unique + email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] decks Deck[] + studySessions StudySession[] } model VerificationToken { @@ -59,29 +60,80 @@ enum Visibility { } model Deck { - id String @id @default(cuid()) - title String - description String - visibility Visibility @default(Public) - ownerId String - image String @unique - updatedAt DateTime @default(now()) @updatedAt - createdAt DateTime @default(now()) - user User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - topics Topic[] - cards Card[] + id String @id @default(cuid()) + title String + description String + visibility Visibility @default(Public) + ownerId String + image String @unique + updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + user User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + topics Topic[] + cards Card[] + studySessions StudySession[] } model Topic { - id String @id @default(cuid()) - title String @unique - decks Deck[] + id String @id @default(cuid()) + title String @unique + decks Deck[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt } model Card { - id String @id @default(cuid()) - question String - answer String - deckId String - deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + question String + answer String + deckId String + deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + studySessionBoxCards StudySessionBoxCard[] +} + +model StudySession { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + deckId String + deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) + studySessionBoxes StudySessionBox[] + + @@unique([userId, deckId]) +} + +model StudySessionBox { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + studySessionId String + studySession StudySession @relation(fields: [studySessionId], references: [id], onDelete: Cascade) + lastReview DateTime @default(now()) + reviewGapInHours Int + studySessionBoxCards StudySessionBoxCard[] +} + +model StudySessionBoxCard { + id String @id @default(cuid()) + studySessionBoxId String + studySessionBox StudySessionBox @relation(fields: [studySessionBoxId], references: [id], onDelete: Cascade) + cardId String + card Card @relation(fields: [cardId], references: [id], onDelete: Cascade) + studySessionAttempts StudySessionAttempt[] + + @@unique([studySessionBoxId, cardId]) +} + +model StudySessionAttempt { + id String @id @default(cuid()) + answer String + isRight Boolean + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + studySessionBoxCardId String + studySessionBoxCard StudySessionBoxCard @relation(fields: [studySessionBoxCardId], references: [id], onDelete: Cascade) } diff --git a/src/constants/index.ts b/src/constants/index.ts index 23d9b07..a6ee409 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -21,3 +21,11 @@ export const DECK_VISIBILITY_OPTIONS: Array> = [ description: 'Todo com acesso direto ao link do Deck', }, ] + +export const STUDY_SESSION_BOXES = [ + { reviewGapInHours: 24 }, + { reviewGapInHours: 24 * 3 }, + { reviewGapInHours: 24 * 7 }, + { reviewGapInHours: 24 * 15 }, + { reviewGapInHours: 24 * 20 }, +] diff --git a/src/pages/decks/[id].tsx b/src/pages/decks/[id].tsx index 569a233..741b22b 100644 --- a/src/pages/decks/[id].tsx +++ b/src/pages/decks/[id].tsx @@ -5,6 +5,7 @@ import { PencilSquareIcon, TrashIcon, EllipsisVerticalIcon, + RectangleStackIcon, } from '@heroicons/react/24/outline' import { Visibility } from '@prisma/client' import { useSetAtom } from 'jotai' @@ -15,6 +16,7 @@ import dynamic from 'next/dynamic' import Head from 'next/head' import { useRouter } from 'next/router' +import { Button } from '~/components/button' import { Card } from '~/components/card' import { Image } from '~/components/image' import { getServerAuthSession } from '~/server/common/auth' @@ -23,6 +25,7 @@ import { getS3ImageUrl } from '~/server/common/s3' import { api, handleApiClientSideError } from '~/utils/api' import { fullScreenLoaderAtom } from '~/utils/atoms' import { classNames } from '~/utils/css' +import { formatToDayMonthYearWithHourAndSeconds } from '~/utils/date-time' import { routes } from '~/utils/navigation' import { notify } from '~/utils/toast' @@ -98,6 +101,7 @@ function ActionsDropDown({ const router = useRouter() const setIsLoading = useSetAtom(fullScreenLoaderAtom) const deleteDeckMutation = api.decks.deleteDeck.useMutation() + const createStudySessionMutation = api.studySession.create.useMutation() const actions = [ { @@ -123,6 +127,24 @@ function ActionsDropDown({ } }, }, + { + label: 'Estudar com este Deck', + icon: RectangleStackIcon, + onClick: async () => { + try { + setIsLoading(true) + + await createStudySessionMutation.mutateAsync({ deckId }) + + notify.success('Sessão de estudo criada com sucesso!') + router.back() + } catch (error) { + handleApiClientSideError({ error }) + } finally { + setIsLoading(false) + } + }, + }, ] return ( @@ -173,12 +195,61 @@ function ActionsDropDown({ ) } +const StudySessionCard = (props: { deckId: string }) => { + const { deckId } = props + + const { data, isLoading } = + api.studySession.getLastAndNextReviewDates.useQuery({ deckId }) + + if (isLoading) { + return ( +
+ Loading... +
+ ) + } + + if (!data) return null + + const { lastReviewDateTime, nextReviewDateTime } = data + + return ( +
+
+ +
+

Sessão de estudos atual

+

+ {`Última revisão: ${formatToDayMonthYearWithHourAndSeconds( + lastReviewDateTime, + )}`} +

+

+ {`Próxima revisão: ${formatToDayMonthYearWithHourAndSeconds( + nextReviewDateTime, + )}`} +

+
+
+
+ +
+
+ +
+
+ ) +} + const DeckDetails: NextPage< InferGetServerSidePropsType > = props => { const { deck } = props const { data: session } = useSession() + const isAuthenticated = !!session?.user const renderTopics = () => { if (deck.topics.length === 0) return null @@ -197,10 +268,16 @@ const DeckDetails: NextPage< ) } + const renderCurrentStudySessionCard = () => { + if (!isAuthenticated) return null + + return + } + const renderActionButtons = () => { const isCurrentUserDeckOwner = session?.user?.id === deck.ownerId - if (!session?.user || !isCurrentUserDeckOwner) return null + if (!isAuthenticated || !isCurrentUserDeckOwner) return null return ( @@ -237,6 +314,7 @@ const DeckDetails: NextPage< {renderTopics()}
+ {renderCurrentStudySessionCard()}

Cards:

    {deck.cards.map(card => ( diff --git a/src/server/api/root.ts b/src/server/api/root.ts index d8cf9b9..0c7daa7 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,5 +1,6 @@ import { decksRouter } from './routers/decks' import { filesRouter } from './routers/files' +import { studySessionRouter } from './routers/study-session' import { createTRPCRouter } from './trpc' /** @@ -10,6 +11,7 @@ import { createTRPCRouter } from './trpc' export const appRouter = createTRPCRouter({ decks: decksRouter, files: filesRouter, + studySession: studySessionRouter, }) export type AppRouter = typeof appRouter diff --git a/src/server/api/routers/study-session/index.ts b/src/server/api/routers/study-session/index.ts new file mode 100644 index 0000000..e149e8e --- /dev/null +++ b/src/server/api/routers/study-session/index.ts @@ -0,0 +1 @@ +export { studySessionRouter } from './study-session.router' diff --git a/src/server/api/routers/study-session/study-session.router.ts b/src/server/api/routers/study-session/study-session.router.ts new file mode 100644 index 0000000..c5164fa --- /dev/null +++ b/src/server/api/routers/study-session/study-session.router.ts @@ -0,0 +1,113 @@ +import { Visibility } from '@prisma/client' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +import { STUDY_SESSION_BOXES } from '~/constants' +import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc' +import { addHours } from '~/utils/date-time' + +export const studySessionRouter = createTRPCRouter({ + create: protectedProcedure + .input( + z.object({ + deckId: z.string().min(1), + }), + ) + .mutation(async ({ input: { deckId }, ctx }) => { + const hasStudySession = !!(await ctx.prisma.studySession.findFirst({ + where: { deckId, userId: ctx.session.user.id }, + })) + + if (hasStudySession) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Você já tem uma sessão de estudos vinculada a este Deck', + }) + } + + const deck = await ctx.prisma.deck.findFirst({ + where: { id: deckId }, + include: { cards: true }, + }) + + if (!deck) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Deck não foi encontrado em nossa base de dados', + }) + } + + const isDeckPrivate = deck.visibility === Visibility.Private + const isDeckOwner = deck.ownerId === ctx.session.user.id + + if (isDeckPrivate && !isDeckOwner) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: + 'Para iniciar uma sessão de estudos o deck não deve ser privado ou vocês deve ser o dono do Deck', + }) + } + + // TODO emiliosheinz: Improve endpoint performance by grouping queries + const studySession = await ctx.prisma.studySession.create({ + data: { + deckId, + userId: ctx.session.user.id, + }, + }) + + const [firstStudySessionBox] = await ctx.prisma.$transaction( + STUDY_SESSION_BOXES.map(({ reviewGapInHours }) => + ctx.prisma.studySessionBox.create({ + data: { + reviewGapInHours, + studySessionId: studySession.id, + }, + }), + ), + ) + + await ctx.prisma.studySessionBoxCard.createMany({ + data: deck.cards.map(({ id }) => ({ + cardId: id, + studySessionBoxId: firstStudySessionBox!.id, + })), + }) + }), + getLastAndNextReviewDates: protectedProcedure + .input( + z.object({ + deckId: z.string().min(1), + }), + ) + .query(async ({ input: { deckId }, ctx }) => { + await new Promise(r => setTimeout(r, 3000)) + const studySession = await ctx.prisma.studySession.findFirst({ + where: { deckId: deckId }, + }) + + if (!studySession) return null + + const studySessionBoxes = await ctx.prisma.studySessionBox.findMany({ + where: { studySessionId: studySession.id }, + orderBy: { lastReview: 'desc' }, + }) + + let nextReviewDateTime + + for (const box of studySessionBoxes) { + const currentReviewDate = addHours(box.lastReview, box.reviewGapInHours) + + if (!nextReviewDateTime) { + nextReviewDateTime = currentReviewDate + } else if (currentReviewDate < nextReviewDateTime) { + nextReviewDateTime = currentReviewDate + } + } + + return { + nextReviewDateTime, + lastReviewDateTime: studySessionBoxes[0]?.lastReview, + } + }), +}) diff --git a/src/utils/date-time/index.ts b/src/utils/date-time/index.ts new file mode 100644 index 0000000..cf83103 --- /dev/null +++ b/src/utils/date-time/index.ts @@ -0,0 +1,17 @@ +export function formatToDayMonthYearWithHourAndSeconds(dateTime?: Date) { + if (!dateTime) return 'Data Inválida' + + return dateTime.toLocaleString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function addHours(dateTime: Date, hours: number) { + const copiedDate = new Date(dateTime.getTime()) + copiedDate.setTime(copiedDate.getTime() + hours * 60 * 60 * 1000) + return copiedDate +} From 1afa25fd34947ad479e5f872e8c14309954ae74c Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sun, 22 Jan 2023 19:10:07 -0300 Subject: [PATCH 6/8] refactor: :recycle: adds modules forlder with page specific components --- .../decks/actions-drop-down.component.tsx | 136 ++++++++++++ src/modules/decks/create/cards.component.tsx | 71 ++++++ .../decks/create/main-info.component.tsx | 41 ++++ .../decks/create/submit-buttons.component.tsx | 16 ++ src/modules/decks/create/topics.component.tsx | 38 ++++ .../decks/create/visibility.component.tsx | 16 ++ .../decks/study-session-card.component.tsx | 53 +++++ src/pages/decks/[id].tsx | 196 ++--------------- src/pages/decks/create/[id].tsx | 208 +++--------------- 9 files changed, 416 insertions(+), 359 deletions(-) create mode 100644 src/modules/decks/actions-drop-down.component.tsx create mode 100644 src/modules/decks/create/cards.component.tsx create mode 100644 src/modules/decks/create/main-info.component.tsx create mode 100644 src/modules/decks/create/submit-buttons.component.tsx create mode 100644 src/modules/decks/create/topics.component.tsx create mode 100644 src/modules/decks/create/visibility.component.tsx create mode 100644 src/modules/decks/study-session-card.component.tsx diff --git a/src/modules/decks/actions-drop-down.component.tsx b/src/modules/decks/actions-drop-down.component.tsx new file mode 100644 index 0000000..91192f2 --- /dev/null +++ b/src/modules/decks/actions-drop-down.component.tsx @@ -0,0 +1,136 @@ +import { Fragment } from 'react' + +import { Menu, Transition } from '@headlessui/react' +import { + EllipsisVerticalIcon, + PencilSquareIcon, + RectangleStackIcon, + TrashIcon, +} from '@heroicons/react/24/outline' +import { useSetAtom } from 'jotai' +import { useSession } from 'next-auth/react' +import { useRouter } from 'next/router' + +import { api, handleApiClientSideError } from '~/utils/api' +import { fullScreenLoaderAtom } from '~/utils/atoms' +import { classNames } from '~/utils/css' +import { routes } from '~/utils/navigation' +import { notify } from '~/utils/toast' + +type ActionsDropDownProps = { + deckOwnerId: string + className?: string + deckId: string +} + +export const ActionsDropDown = (props: ActionsDropDownProps) => { + const { deckOwnerId, className, deckId } = props + + const router = useRouter() + const setIsLoading = useSetAtom(fullScreenLoaderAtom) + + const deleteDeckMutation = api.decks.deleteDeck.useMutation() + const createStudySessionMutation = api.studySession.create.useMutation() + + const { data: session } = useSession() + const isAuthenticated = !!session?.user + const isUserDeckOwner = session?.user?.id === deckOwnerId + + const actions = [ + { + label: 'Editar Deck', + icon: PencilSquareIcon, + isEnabled: isAuthenticated && isUserDeckOwner, + onClick: () => router.push(routes.editDeck(deckId)), + }, + { + label: 'Excluir Deck', + icon: TrashIcon, + isEnabled: isAuthenticated && isUserDeckOwner, + onClick: async () => { + try { + setIsLoading(true) + + await deleteDeckMutation.mutateAsync({ id: deckId }) + + notify.success('Deck excluído com sucesso!') + router.back() + } catch (error) { + handleApiClientSideError({ error }) + } finally { + setIsLoading(false) + } + }, + }, + { + label: 'Estudar com este Deck', + icon: RectangleStackIcon, + isEnabled: isAuthenticated, + onClick: async () => { + try { + setIsLoading(true) + + await createStudySessionMutation.mutateAsync({ deckId }) + + notify.success('Sessão de estudo criada com sucesso!') + router.back() + } catch (error) { + handleApiClientSideError({ error }) + } finally { + setIsLoading(false) + } + }, + }, + ] + + const hasEnabledActions = actions.some(({ isEnabled }) => isEnabled) + + if (!hasEnabledActions) return null + + return ( +
    + +
    + + +
    + + +
    + {actions.map(action => ( + + {({ active }) => ( + + )} + + ))} +
    +
    +
    +
    +
    + ) +} diff --git a/src/modules/decks/create/cards.component.tsx b/src/modules/decks/create/cards.component.tsx new file mode 100644 index 0000000..213f872 --- /dev/null +++ b/src/modules/decks/create/cards.component.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' + +import { PlusCircleIcon } from '@heroicons/react/24/outline' + +import { Card } from '~/components/card' +import { NewCardModal } from '~/components/modal/new-card/new-card-modal.component' +import type { CardFormInputValues } from '~/components/modal/new-card/new-card-modal.types' +import { useCreateNewDeckContext } from '~/contexts/create-new-deck' + +type NewCardModalState = { + isOpen: boolean + cardIdx?: number +} + +export const Cards = () => { + const { cards, addCard, deleteCard, editCard } = useCreateNewDeckContext() + + const [newCardModalState, setNewCardModalState] = useState( + { isOpen: false }, + ) + + const isCreatingNewCard = newCardModalState.cardIdx === undefined + + const modalFieldsValues = isCreatingNewCard + ? { answer: '', question: '' } + : cards[newCardModalState.cardIdx!] + + const handleNewCardFormSubmit = (values: CardFormInputValues) => { + if (isCreatingNewCard) { + addCard(values) + } else { + editCard(newCardModalState.cardIdx!, values) + } + } + + const closeModal = (isOpen: boolean) => { + setNewCardModalState(state => ({ + ...state, + isOpen, + })) + } + + return ( + <> +

    Cards

    +
    + {cards.map((card, idx) => ( + deleteCard(idx)} + onEditPress={() => { + setNewCardModalState({ isOpen: true, cardIdx: idx }) + }} + > + {card.question} + + ))} + setNewCardModalState({ isOpen: true })}> + + +
    + + + ) +} diff --git a/src/modules/decks/create/main-info.component.tsx b/src/modules/decks/create/main-info.component.tsx new file mode 100644 index 0000000..c23b45c --- /dev/null +++ b/src/modules/decks/create/main-info.component.tsx @@ -0,0 +1,41 @@ +import { ImageUploader } from '~/components/image-uploader' +import { Input } from '~/components/input' +import { TextArea } from '~/components/text-area' +import { useCreateNewDeckContext } from '~/contexts/create-new-deck' + +export const MainInfo = () => { + const { createNewDeckForm } = useCreateNewDeckContext() + + const { formState, register } = createNewDeckForm ?? {} + + return ( + <> +

    Criar Deck

    +
    +
    + +
    +
    + +