diff --git a/.all-contributorsrc b/.all-contributorsrc index f0fe22b7..132e8017 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -117,14 +117,14 @@ "login": "AdiPol1359", "name": "Adrian Polak", "avatar_url": "https://avatars.githubusercontent.com/u/27779154?v=4", - "profile": "https://projectcode.pl/", + "profile": "https://github.com/AdiPol1359", "contributions": [ "code" ] }, { "login": "xStrixU", - "name": "xStrixU", + "name": "Kacper Polak", "avatar_url": "https://avatars.githubusercontent.com/u/41890821?v=4", "profile": "https://github.com/xStrixU", "contributions": [ diff --git a/.vscode/settings.json b/.vscode/settings.json index 1b6500b0..fc77221f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,9 @@ "titleBar.inactiveBackground": "#401886", "titleBar.activeForeground": "#ffffff", "titleBar.inactiveForeground": "#ffffff" - } + }, + "[prisma]": { + "editor.defaultFormatter": "Prisma.prisma" + }, + "prettier.configPath": "prettier.config.js" } diff --git a/apps/api/modules/answers/answers.mapper.ts b/apps/api/modules/answers/answers.mapper.ts index b4012500..004359d1 100644 --- a/apps/api/modules/answers/answers.mapper.ts +++ b/apps/api/modules/answers/answers.mapper.ts @@ -6,13 +6,17 @@ export const dbAnswerToDto = ({ content, sources, createdAt, + QuestionAnswerVote, CreatedBy: { socialLogin, ...createdBy }, -}: Prisma.QuestionAnswerGetPayload<{ select: typeof answerSelect }>) => { + _count, +}: Prisma.QuestionAnswerGetPayload<{ select: ReturnType }>) => { return { id, content, sources, createdAt: createdAt.toISOString(), + votesCount: _count.QuestionAnswerVote, + currentUserVotedOn: QuestionAnswerVote.length > 0, createdBy: { socialLogin: socialLogin as Record, ...createdBy, diff --git a/apps/api/modules/answers/answers.params.ts b/apps/api/modules/answers/answers.params.ts new file mode 100644 index 00000000..402c5f98 --- /dev/null +++ b/apps/api/modules/answers/answers.params.ts @@ -0,0 +1,22 @@ +import { Prisma } from "@prisma/client"; +import { kv } from "../../utils.js"; +import { GetAnswersQuery } from "./answers.schemas"; + +export const getAnswersPrismaParams = ({ limit, offset, order, orderBy }: GetAnswersQuery) => { + return { + take: limit, + skip: offset, + ...(order && + orderBy && { + orderBy: { + ...(orderBy === "votesCount" + ? { + QuestionAnswerVote: { + _count: order, + }, + } + : kv(orderBy, order)), + }, + }), + } satisfies Prisma.QuestionAnswerFindManyArgs; +}; diff --git a/apps/api/modules/answers/answers.routes.ts b/apps/api/modules/answers/answers.routes.ts index fa69795c..68bfdbe1 100644 --- a/apps/api/modules/answers/answers.routes.ts +++ b/apps/api/modules/answers/answers.routes.ts @@ -4,22 +4,37 @@ import { FastifyPluginAsync, preHandlerAsyncHookHandler, preHandlerHookHandler } import { PrismaErrorCode } from "../db/prismaErrors.js"; import { isPrismaError } from "../db/prismaErrors.util.js"; import { dbAnswerToDto } from "./answers.mapper.js"; +import { getAnswersPrismaParams } from "./answers.params.js"; import { - getAnswersSchema, + getAnswersRelatedToPostSchema, createAnswerSchema, deleteAnswerSchema, updateAnswerSchema, + upvoteAnswerSchema, + getAnswersSchema, } from "./answers.schemas.js"; -export const answerSelect = { - id: true, - content: true, - sources: true, - createdAt: true, - CreatedBy: { - select: { id: true, firstName: true, lastName: true, socialLogin: true }, - }, -} satisfies Prisma.QuestionAnswerSelect; +export const answerSelect = (userId: number) => { + return { + id: true, + content: true, + sources: true, + createdAt: true, + CreatedBy: { + select: { id: true, firstName: true, lastName: true, socialLogin: true }, + }, + _count: { + select: { + QuestionAnswerVote: true, + }, + }, + QuestionAnswerVote: { + where: { + userId: userId, + }, + }, + } satisfies Prisma.QuestionAnswerSelect; +}; const answersPlugin: FastifyPluginAsync = async (fastify) => { const checkAnswerUserHook: preHandlerAsyncHookHandler = async (request) => { @@ -47,21 +62,72 @@ const answersPlugin: FastifyPluginAsync = async (fastify) => { }; fastify.withTypeProvider().route({ - url: "/questions/:id/answers", + url: "/answers", method: "GET", schema: getAnswersSchema, + async handler(request) { + const params = getAnswersPrismaParams(request.query); + const [total, answers] = await Promise.all([ + fastify.db.questionAnswer.count(), + fastify.db.questionAnswer.findMany({ + ...params, + select: { + id: true, + content: true, + sources: true, + createdAt: true, + updatedAt: true, + CreatedBy: { + select: { id: true, firstName: true, lastName: true, socialLogin: true }, + }, + _count: { + select: { + QuestionAnswerVote: true, + }, + }, + }, + }), + ]); + + return { + data: answers.map((a) => { + return { + id: a.id, + content: a.content, + sources: a.sources, + createdAt: a.createdAt.toISOString(), + updatedAt: a.createdAt.toISOString(), + createdBy: { + id: a.CreatedBy.id, + firstName: a.CreatedBy.firstName, + lastName: a.CreatedBy.lastName, + socialLogin: a.CreatedBy.socialLogin as Record, + }, + votesCount: a._count.QuestionAnswerVote, + }; + }), + meta: { total }, + }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id/answers", + method: "GET", + schema: getAnswersRelatedToPostSchema, async handler(request) { const { params: { id }, + session: { data: sessionData }, } = request; const answers = await fastify.db.questionAnswer.findMany({ where: { questionId: id }, - select: answerSelect, + select: answerSelect(request.session.data?._user.id || 0), }); return { - data: answers.map(dbAnswerToDto), + data: answers.map((answer) => dbAnswerToDto(answer)), }; }, }); @@ -84,7 +150,7 @@ const answersPlugin: FastifyPluginAsync = async (fastify) => { try { const answer = await fastify.db.questionAnswer.create({ data: { questionId: id, createdById: sessionData._user.id, content, sources }, - select: answerSelect, + select: answerSelect(request.session.data?._user.id || 0), }); return { data: dbAnswerToDto(answer) }; @@ -109,12 +175,13 @@ const answersPlugin: FastifyPluginAsync = async (fastify) => { const { params: { id }, body: { content, sources }, + session: { data: sessionData }, } = request; const answer = await fastify.db.questionAnswer.update({ where: { id }, data: { content, sources }, - select: answerSelect, + select: answerSelect(request.session.data?._user.id || 0), }); return { data: dbAnswerToDto(answer) }; @@ -138,6 +205,89 @@ const answersPlugin: FastifyPluginAsync = async (fastify) => { return reply.status(204).send(); }, }); + + fastify.withTypeProvider().route({ + url: "/answers/:id/votes", + method: "POST", + schema: upvoteAnswerSchema, + async handler(request, reply) { + const { + params: { id }, + session: { data: sessionData }, + } = request; + + if (!sessionData) { + throw fastify.httpErrors.unauthorized(); + } + + try { + const questionAnswerVote = await fastify.db.questionAnswerVote.upsert({ + where: { + userId_questionAnswerId: { + userId: sessionData._user.id, + questionAnswerId: id, + }, + }, + create: { + userId: sessionData._user.id, + questionAnswerId: id, + }, + update: { + userId: sessionData._user.id, + questionAnswerId: id, + }, + }); + + return { + data: { + userId: sessionData._user.id, + answerId: id, + }, + }; + } catch (err) { + if (isPrismaError(err) && PrismaErrorCode.ForeignKeyViolation) { + throw fastify.httpErrors.notFound(`Answer vote with id: ${id} not found!`); + } + + throw err; + } + }, + }); + + fastify.withTypeProvider().route({ + url: "/answers/:id/votes", + method: "DELETE", + schema: deleteAnswerSchema, + async handler(request, reply) { + const { + params: { id }, + session: { data: sessionData }, + } = request; + + if (!sessionData) { + throw fastify.httpErrors.unauthorized(); + } + + const questionAnswer = await fastify.db.questionAnswer.findFirst({ + where: { + id, + }, + }); + + if (!questionAnswer) { + throw fastify.httpErrors.notFound(`Answer vote with id: ${id} not found!`); + } + + await fastify.db.questionAnswerVote.deleteMany({ + where: { + userId: sessionData._user.id, + questionAnswerId: id, + }, + }); + + return reply.status(204).send(); + }, + }); }; export default answersPlugin; diff --git a/apps/api/modules/answers/answers.schemas.ts b/apps/api/modules/answers/answers.schemas.ts index 8f562cd7..b8bcb8d6 100644 --- a/apps/api/modules/answers/answers.schemas.ts +++ b/apps/api/modules/answers/answers.schemas.ts @@ -1,10 +1,12 @@ -import { Type } from "@sinclair/typebox"; +import { Static, Type } from "@sinclair/typebox"; const answerSchema = Type.Object({ id: Type.Number(), content: Type.String(), sources: Type.Array(Type.String()), createdAt: Type.String({ format: "date-time" }), + votesCount: Type.Integer(), + currentUserVotedOn: Type.Boolean(), createdBy: Type.Object({ id: Type.Integer(), firstName: Type.Union([Type.String(), Type.Null()]), @@ -13,7 +15,7 @@ const answerSchema = Type.Object({ }), }); -export const getAnswersSchema = { +export const getAnswersRelatedToPostSchema = { params: Type.Object({ id: Type.Integer(), }), @@ -64,3 +66,68 @@ export const deleteAnswerSchema = { 204: Type.Never(), }, }; + +export const upvoteAnswerSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + response: { + 200: Type.Object({ + data: Type.Object({ + userId: Type.Integer(), + answerId: Type.Integer(), + }), + }), + }, +}; + +export const downvoteAnswerSchema = { + params: Type.Object({ + id: Type.Integer(), + }), + response: { + 204: Type.Never(), + }, +}; + +const generateGetAnswersQuerySchema = Type.Partial( + Type.Object({ + limit: Type.Integer(), + offset: Type.Integer(), + orderBy: Type.Union([ + Type.Literal("createdAt"), + Type.Literal("updatedAt"), + Type.Literal("votesCount"), + ]), + order: Type.Union([Type.Literal("asc"), Type.Literal("desc")]), + }), +); + +export const getAnswersSchema = { + querystring: generateGetAnswersQuerySchema, + response: { + 200: Type.Object({ + data: Type.Array( + Type.Object({ + id: Type.Number(), + content: Type.String(), + sources: Type.Array(Type.String()), + createdAt: Type.String({ format: "date-time" }), + updatedAt: Type.String({ format: "date-time" }), + createdBy: Type.Object({ + id: Type.Integer(), + firstName: Type.Union([Type.String(), Type.Null()]), + lastName: Type.Union([Type.String(), Type.Null()]), + socialLogin: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number()])), + }), + votesCount: Type.Integer(), + }), + ), + meta: Type.Object({ + total: Type.Integer(), + }), + }), + }, +}; + +export type GetAnswersQuery = Static; diff --git a/apps/api/modules/questions/questions.params.ts b/apps/api/modules/questions/questions.params.ts index eb8fc0dd..8a1e6866 100644 --- a/apps/api/modules/questions/questions.params.ts +++ b/apps/api/modules/questions/questions.params.ts @@ -1,9 +1,18 @@ import { Prisma } from "@prisma/client"; import { kv } from "../../utils.js"; -import { GetQuestionsQuery } from "./questions.schemas.js"; +import { GetQuestionsQuery } from "./questions.schemas"; export const getQuestionsPrismaParams = ( - { category, level, status = "accepted", limit, offset, order, orderBy }: GetQuestionsQuery, + { + category, + level, + status = "accepted", + limit, + offset, + order, + orderBy, + userId, + }: GetQuestionsQuery, userRole: string | undefined, ) => { const levels = level?.split(","); @@ -13,6 +22,7 @@ export const getQuestionsPrismaParams = ( ...(category && { categoryId: category }), ...(levels && { levelId: { in: levels } }), ...(status && userRole === "admin" ? { statusId: status } : { statusId: "accepted" }), + ...(userId && { createdById: userId }), }, take: limit, skip: offset, diff --git a/apps/api/modules/questions/questions.routes.ts b/apps/api/modules/questions/questions.routes.ts index 1afc0180..fc6bc035 100644 --- a/apps/api/modules/questions/questions.routes.ts +++ b/apps/api/modules/questions/questions.routes.ts @@ -49,6 +49,7 @@ const questionsPlugin: FastifyPluginAsync = async (fastify) => { levelId: true, statusId: true, acceptedAt: true, + updatedAt: true, _count: { select: { QuestionVote: true, @@ -66,6 +67,7 @@ const questionsPlugin: FastifyPluginAsync = async (fastify) => { _levelId: q.levelId, _statusId: q.statusId, acceptedAt: q.acceptedAt?.toISOString(), + updatedAt: q.updatedAt?.toISOString(), votesCount: q._count.QuestionVote, }; }); diff --git a/apps/api/modules/questions/questions.schemas.ts b/apps/api/modules/questions/questions.schemas.ts index c14fce6b..89ffe882 100644 --- a/apps/api/modules/questions/questions.schemas.ts +++ b/apps/api/modules/questions/questions.schemas.ts @@ -26,8 +26,10 @@ const generateGetQuestionsQuerySchema = < Type.Literal("acceptedAt"), Type.Literal("level"), Type.Literal("votesCount"), + Type.Literal("updatedAt"), ]), order: Type.Union([Type.Literal("asc"), Type.Literal("desc")]), + userId: Type.Integer(), }), ); export type GetQuestionsQuery = Static>; @@ -50,6 +52,7 @@ const generateQuestionShape = < _levelId: Type.Union(args.levels.map((val) => Type.Literal(val))), _statusId: Type.Union(args.statuses.map((val) => Type.Literal(val))), acceptedAt: Type.Optional(Type.String({ format: "date-time" })), + updatedAt: Type.Optional(Type.String({ format: "date-time" })), } as const; }; diff --git a/apps/api/prisma/migrations/20230106153310_add_question_answers_votes/migration.sql b/apps/api/prisma/migrations/20230106153310_add_question_answers_votes/migration.sql new file mode 100644 index 00000000..7a522d36 --- /dev/null +++ b/apps/api/prisma/migrations/20230106153310_add_question_answers_votes/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "QuestionAnswerVote" ( + "_userId" INTEGER NOT NULL, + "_questionAnswerId" INTEGER NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "QuestionAnswerVote_pkey" PRIMARY KEY ("_userId","_questionAnswerId") +); + +-- AddForeignKey +ALTER TABLE "QuestionAnswerVote" ADD CONSTRAINT "QuestionAnswerVote__questionAnswerId_fkey" FOREIGN KEY ("_questionAnswerId") REFERENCES "QuestionAnswer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuestionAnswerVote" ADD CONSTRAINT "QuestionAnswerVote__userId_fkey" FOREIGN KEY ("_userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b272eefb..e000b232 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -57,15 +57,26 @@ model QuestionVote { } model QuestionAnswer { - id Int @id @default(autoincrement()) - createdById Int @map("_createdById") - questionId Int @map("_questionId") - content String - sources String[] @default([]) - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt() @db.Timestamptz(6) - CreatedBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) - Question Question @relation(fields: [questionId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + createdById Int @map("_createdById") + questionId Int @map("_questionId") + content String + sources String[] @default([]) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + CreatedBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + Question Question @relation(fields: [questionId], references: [id], onDelete: Cascade) + QuestionAnswerVote QuestionAnswerVote[] +} + +model QuestionAnswerVote { + userId Int @map("_userId") + questionAnswerId Int @map("_questionAnswerId") + createdAt DateTime @default(now()) @db.Timestamptz(6) + QuestionAnswer QuestionAnswer @relation(fields: [questionAnswerId], references: [id], onDelete: Cascade, onUpdate: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@id([userId, questionAnswerId]) } model SequelizeMeta { @@ -83,19 +94,20 @@ model Session { } model User { - id Int @id @default(autoincrement()) - email String @unique - firstName String? - lastName String? - roleId String @default("user") @map("_roleId") - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt() @db.Timestamptz(6) - socialLogin Json @default("{}") - UserRole UserRole @relation(fields: [roleId], references: [id], onDelete: Cascade) - QuestionVote QuestionVote[] - Session Session[] - Question Question[] - QuestionAnswer QuestionAnswer[] + id Int @id @default(autoincrement()) + email String @unique + firstName String? + lastName String? + roleId String @default("user") @map("_roleId") + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @db.Timestamptz(6) + socialLogin Json @default("{}") + UserRole UserRole @relation(fields: [roleId], references: [id], onDelete: Cascade) + QuestionVote QuestionVote[] + Session Session[] + Question Question[] + QuestionAnswer QuestionAnswer[] + QuestionAnswerVote QuestionAnswerVote[] } model UserRole { diff --git a/apps/app/next.config.js b/apps/app/next.config.js index 87f0c9ba..0a1ec49b 100644 --- a/apps/app/next.config.js +++ b/apps/app/next.config.js @@ -71,6 +71,21 @@ const nextConfig = { destination: "/jak-korzystac", permanent: false, }, + { + source: "/user", + destination: "/user/questions/1", + permanent: false, + }, + { + source: "/user/questions", + destination: "/user/questions/1", + permanent: false, + }, + { + source: "/answers", + destination: "/answers/1", + permanent: false, + }, ]; }, }; diff --git a/apps/app/src/all-contributorsrc.json b/apps/app/src/all-contributorsrc.json index e1dc8fa8..a4a875f6 100644 --- a/apps/app/src/all-contributorsrc.json +++ b/apps/app/src/all-contributorsrc.json @@ -89,12 +89,12 @@ "login": "AdiPol1359", "name": "Adrian Polak", "avatar_url": "https://avatars.githubusercontent.com/u/27779154?v=4", - "profile": "https://projectcode.pl/", + "profile": "https://github.com/AdiPol1359", "contributions": ["code"] }, { "login": "xStrixU", - "name": "xStrixU", + "name": "Kacper Polak", "avatar_url": "https://avatars.githubusercontent.com/u/41890821?v=4", "profile": "https://github.com/xStrixU", "contributions": ["code"] diff --git a/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx b/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx index 7d57ec22..c71aa4d2 100644 --- a/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx +++ b/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx @@ -5,6 +5,7 @@ import { parseQueryLevels } from "../../../../../lib/level"; import { statuses } from "../../../../../lib/question"; import { parseTechnologyQuery } from "../../../../../lib/technologies"; import { Params, SearchParams } from "../../../../../types"; +import { DEFAULT_SORT_BY_QUERY, parseQuerySortBy } from "../../../../../lib/order"; const AdminPanel = dynamic( () => @@ -19,19 +20,27 @@ export default function AdminPage({ searchParams, }: { params: Params<"status" | "page">; - searchParams?: SearchParams<"technology" | "level">; + searchParams?: SearchParams<"technology" | "level" | "sortBy">; }) { const page = Number.parseInt(params.page); const technology = parseTechnologyQuery(searchParams?.technology); const levels = parseQueryLevels(searchParams?.level); + const sortBy = parseQuerySortBy(searchParams?.sortBy || DEFAULT_SORT_BY_QUERY); if (Number.isNaN(page) || !statuses.includes(params.status)) { return redirect("/admin"); } return ( - - + + ); } diff --git a/apps/app/src/app/(main-layout)/admin/layout.tsx b/apps/app/src/app/(main-layout)/admin/layout.tsx index 36cf50a0..221c5868 100644 --- a/apps/app/src/app/(main-layout)/admin/layout.tsx +++ b/apps/app/src/app/(main-layout)/admin/layout.tsx @@ -2,9 +2,5 @@ import { ReactNode } from "react"; import { Container } from "../../../components/Container"; export default function AdminPageLayout({ children }: { readonly children: ReactNode }) { - return ( - - {children} - - ); + return {children}; } diff --git a/apps/app/src/app/(main-layout)/answers/[page]/page.tsx b/apps/app/src/app/(main-layout)/answers/[page]/page.tsx new file mode 100644 index 00000000..344a8e8c --- /dev/null +++ b/apps/app/src/app/(main-layout)/answers/[page]/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; +import { AnswersDashboard } from "../../../../components/AnswersDashboard/AnswersDashboard"; +import { PrivateRoute } from "../../../../components/PrivateRoute"; +import { DEFAULT_ANSWERS_SORT_BY_QUERY, parseSortByQuery } from "../../../../lib/order"; +import { Params, SearchParams } from "../../../../types"; + +export default function ManageQuestionsAnswers({ + params, + searchParams, +}: { + params: Params<"page">; + searchParams?: SearchParams<"sortBy">; +}) { + const page = Number.parseInt(params.page); + const sortBy = parseSortByQuery(searchParams?.sortBy || DEFAULT_ANSWERS_SORT_BY_QUERY); + + if (Number.isNaN(page)) { + return redirect("/answers/1"); + } + + return ( + + + + ); +} diff --git a/apps/app/src/app/(main-layout)/answers/head.tsx b/apps/app/src/app/(main-layout)/answers/head.tsx new file mode 100644 index 00000000..0e83a998 --- /dev/null +++ b/apps/app/src/app/(main-layout)/answers/head.tsx @@ -0,0 +1,5 @@ +import { HeadTags } from "../../../components/HeadTags"; + +export default function Head() { + return ; +} diff --git a/apps/app/src/app/(main-layout)/answers/layout.tsx b/apps/app/src/app/(main-layout)/answers/layout.tsx new file mode 100644 index 00000000..7bfcf05f --- /dev/null +++ b/apps/app/src/app/(main-layout)/answers/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from "react"; +import { Container } from "../../../components/Container"; + +export default function UserPageLayout({ children }: { readonly children: ReactNode }) { + return {children}; +} diff --git a/apps/app/src/app/(main-layout)/questions/[technology]/[page]/loading.tsx b/apps/app/src/app/(main-layout)/questions/[technology]/[page]/loading.tsx index 189c0339..768f4716 100644 --- a/apps/app/src/app/(main-layout)/questions/[technology]/[page]/loading.tsx +++ b/apps/app/src/app/(main-layout)/questions/[technology]/[page]/loading.tsx @@ -1,5 +1,5 @@ import { Loading } from "../../../../../components/Loading"; export default function LoadingQuestions() { - return ; + return ; } diff --git a/apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx b/apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx index 972be56b..bd9157aa 100644 --- a/apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx +++ b/apps/app/src/app/(main-layout)/questions/[technology]/[page]/page.tsx @@ -46,13 +46,11 @@ export default async function QuestionsPage({
- {meta.total > PAGE_SIZE && ( - `/questions/${params.technology}/${page}`} - /> - )} + `/questions/${params.technology}/${page}`} + />
); } diff --git a/apps/app/src/app/(main-layout)/questions/p/[questionId]/page.tsx b/apps/app/src/app/(main-layout)/questions/p/[questionId]/page.tsx index 80d84a63..beae24d2 100644 --- a/apps/app/src/app/(main-layout)/questions/p/[questionId]/page.tsx +++ b/apps/app/src/app/(main-layout)/questions/p/[questionId]/page.tsx @@ -29,9 +29,9 @@ export default async function SingleQuestionPage({ params }: { params: Params<"q } const answers = await Promise.all( - answersData.data.data.map(async ({ content, ...rest }) => { - const mdxContent = await serializeSource(content); - return { mdxContent, ...rest }; + answersData.data.data.map(async (answer) => { + const mdxContent = await serializeSource(answer.content); + return { mdxContent, ...answer }; }), ); diff --git a/apps/app/src/app/(main-layout)/user/layout.tsx b/apps/app/src/app/(main-layout)/user/layout.tsx new file mode 100644 index 00000000..7bfcf05f --- /dev/null +++ b/apps/app/src/app/(main-layout)/user/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from "react"; +import { Container } from "../../../components/Container"; + +export default function UserPageLayout({ children }: { readonly children: ReactNode }) { + return {children}; +} diff --git a/apps/app/src/app/(main-layout)/user/questions/[page]/page.tsx b/apps/app/src/app/(main-layout)/user/questions/[page]/page.tsx new file mode 100644 index 00000000..9fb363b8 --- /dev/null +++ b/apps/app/src/app/(main-layout)/user/questions/[page]/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import { PrivateRoute } from "../../../../../components/PrivateRoute"; +import { UserQuestions } from "../../../../../components/UserQuestions/UserQuestions"; +import { parseQueryLevels } from "../../../../../lib/level"; +import { DEFAULT_SORT_BY_QUERY, parseQuerySortBy } from "../../../../../lib/order"; +import { parseTechnologyQuery } from "../../../../../lib/technologies"; +import { Params, SearchParams } from "../../../../../types"; + +export default function UserQuestionsPage({ + params, + searchParams, +}: { + params: Params<"page">; + searchParams?: SearchParams<"technology" | "level" | "sortBy">; +}) { + const page = Number.parseInt(params.page); + const technology = parseTechnologyQuery(searchParams?.technology); + const levels = parseQueryLevels(searchParams?.level); + const sortBy = parseQuerySortBy(searchParams?.sortBy || DEFAULT_SORT_BY_QUERY); + + if (Number.isNaN(page)) { + return redirect("/user/questions"); + } + + return ( + + + + ); +} diff --git a/apps/app/src/app/layout.tsx b/apps/app/src/app/layout.tsx index 3e3559d9..a45ac374 100644 --- a/apps/app/src/app/layout.tsx +++ b/apps/app/src/app/layout.tsx @@ -20,7 +20,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) lang="pl-PL" prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb#" itemType="http://schema.org/WebPage" - className={`${firaSans.variable} ${firaCode.variable}`} + className={`${firaSans.variable} ${firaCode.variable} sm:scrollbar-gutter-stable`} > {children} diff --git a/apps/app/src/components/AddQuestionModal.tsx b/apps/app/src/components/AddQuestionModal.tsx index 0a192068..1cf51461 100644 --- a/apps/app/src/components/AddQuestionModal.tsx +++ b/apps/app/src/components/AddQuestionModal.tsx @@ -54,7 +54,7 @@ export const AddQuestionModal = (props: ComponentProps) => { onSuccess: () => { setSelectData({}); setContent(""); - modalData ? closeModal() : openModal("AddQuestionConfirmationModal"); + modalData ? handleCloseModal() : openModal("AddQuestionConfirmationModal"); }, onError: () => setIsError(true), }; @@ -78,8 +78,15 @@ export const AddQuestionModal = (props: ComponentProps) => { } }; + const handleCloseModal = () => { + closeModal(); + if (modalData?.additionalActionOnClose) { + modalData.additionalActionOnClose(); + } + }; + return ( - + {modalData ? "Edytuj" : "Nowe"} pytanie @@ -135,7 +142,7 @@ export const AddQuestionModal = (props: ComponentProps) => { + {status === "accepted" && ( + - {status === "accepted" && ( - - )} - + )} {status == "pending" && ( -
+ <> -
+ )} ); diff --git a/apps/app/src/components/AdminPanel/AdminPanelQuestionsList.tsx b/apps/app/src/components/AdminPanel/AdminPanelQuestionsList.tsx index 48f78f32..3ae9196f 100644 --- a/apps/app/src/components/AdminPanel/AdminPanelQuestionsList.tsx +++ b/apps/app/src/components/AdminPanel/AdminPanelQuestionsList.tsx @@ -3,6 +3,7 @@ import { serializeQuestionToMarkdown } from "../../lib/question"; import { QuestionItem } from "../QuestionItem/QuestionItem"; import type { APIQuestion } from "../../types"; import { QuestionTechnology } from "../QuestionItem/QuestionTechnology"; +import { QuestionLevel } from "../QuestionItem/QuestionLevel"; import { AdminPanelQuestionLeftSection } from "./AdminPanelQuestionLeftSection"; type AdminPanelQuestionsListProps = Readonly<{ @@ -34,7 +35,12 @@ export const AdminPanelQuestionsList = memo( refetchQuestions={refetchQuestions} /> } - rightSection={} + rightSection={ +
+ + +
+ } {...question} /> diff --git a/apps/app/src/components/AnswersDashboard/AnswersDashboard.tsx b/apps/app/src/components/AnswersDashboard/AnswersDashboard.tsx new file mode 100644 index 00000000..4b7df1ac --- /dev/null +++ b/apps/app/src/components/AnswersDashboard/AnswersDashboard.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Suspense, useCallback } from "react"; +import { useGetAllAnswers } from "../../hooks/useGetAllAnswers"; +import { Order, AnswersOrderBy } from "../../lib/order"; +import { Loading } from "../Loading"; +import { AnswersList } from "./AnswersList"; +import { FilterableAnswersList } from "./FilterableAnswersList"; + +type AnswersDashboardType = { + page: number; + orderBy?: AnswersOrderBy; + order?: Order; +}; + +export const AnswersDashboard = ({ page, orderBy, order }: AnswersDashboardType) => { + const { isSuccess, data, refetch } = useGetAllAnswers({ + page, + orderBy, + order, + }); + + const refetchAnswers = useCallback(() => { + void refetch(); + }, [refetch]); + + return ( + `/answers/${page}`} + data={{ orderBy, order }} + > + {isSuccess && data.data.data.length > 0 ? ( + }> + + + ) : ( +

+ Nie znaleziono żadnej odpowiedzi. +

+ )} +
+ ); +}; diff --git a/apps/app/src/components/AnswersDashboard/AnswersList.tsx b/apps/app/src/components/AnswersDashboard/AnswersList.tsx new file mode 100644 index 00000000..0b557180 --- /dev/null +++ b/apps/app/src/components/AnswersDashboard/AnswersList.tsx @@ -0,0 +1,28 @@ +import { use } from "react"; +import { serializeAnswerToMarkdown } from "../../lib/answer"; +import { APIAnswers } from "../../types"; +import { Answer } from "../QuestionAnswers/Answer"; + +type AnswersListProps = Readonly<{ + answers: APIAnswers; + refetchAnswers: () => void; +}>; + +export const AnswersList = ({ answers, refetchAnswers }: AnswersListProps) => { + const serializedAnswers = answers.map((answer) => + use( + (async () => { + const { mdxContent } = await serializeAnswerToMarkdown(answer); + return { ...answer, mdxContent }; + })(), + ), + ); + + return ( +
+ {serializedAnswers.map((answer) => ( + + ))} +
+ ); +}; diff --git a/apps/app/src/components/AnswersDashboard/FilterableAnswersList.tsx b/apps/app/src/components/AnswersDashboard/FilterableAnswersList.tsx new file mode 100644 index 00000000..5e29e804 --- /dev/null +++ b/apps/app/src/components/AnswersDashboard/FilterableAnswersList.tsx @@ -0,0 +1,29 @@ +import { ComponentProps, ReactNode } from "react"; +import { AnswersOrderBy, Order } from "../../lib/order"; +import { QuestionsPagination } from "../QuestionsPagination/QuestionsPagination"; +import { FilterableAnswersListHeader } from "./FilterableAnswersListHeader"; + +type FilterableAnswersListProps = { + page: number; + children: ReactNode; + data: { + orderBy?: AnswersOrderBy; + order?: Order; + }; +} & Omit, "current">; + +export const FilterableAnswersList = ({ + page, + total, + getHref, + children, + data, +}: FilterableAnswersListProps) => { + return ( +
+ + {children} + +
+ ); +}; diff --git a/apps/app/src/components/AnswersDashboard/FilterableAnswersListHeader.tsx b/apps/app/src/components/AnswersDashboard/FilterableAnswersListHeader.tsx new file mode 100644 index 00000000..e895771e --- /dev/null +++ b/apps/app/src/components/AnswersDashboard/FilterableAnswersListHeader.tsx @@ -0,0 +1,34 @@ +import { ChangeEvent } from "react"; +import { useDevFAQRouter } from "../../hooks/useDevFAQRouter"; +import { Order, answersSortByLabels, AnswersOrderBy } from "../../lib/order"; +import { SortBySelect } from "../SortBySelect"; + +type FilterableAnswersListHeaderProps = Readonly<{ + order?: Order; + orderBy?: AnswersOrderBy; +}>; + +export const FilterableAnswersListHeader = ({ + order, + orderBy, +}: FilterableAnswersListHeaderProps) => { + const { mergeQueryParams } = useDevFAQRouter(); + + const handleSelectChange = (param: string) => (event: ChangeEvent) => { + event.preventDefault(); + mergeQueryParams({ [param]: event.target.value }); + }; + + return ( +
+ {order && orderBy && ( + + )} +
+ ); +}; diff --git a/apps/app/src/components/BaseModal/BaseModal.tsx b/apps/app/src/components/BaseModal/BaseModal.tsx index 714f47e4..bf64deaa 100644 --- a/apps/app/src/components/BaseModal/BaseModal.tsx +++ b/apps/app/src/components/BaseModal/BaseModal.tsx @@ -1,11 +1,12 @@ "use client"; -import { ReactNode, useEffect } from "react"; +import { ReactNode, useCallback, useEffect } from "react"; import { Transition } from "@headlessui/react"; import FocusLock from "react-focus-lock"; import { lockScroll, unlockScroll } from "../../utils/pageScroll"; import { useUIContext } from "../../providers/UIProvider"; import { CloseButton } from "../CloseButton/CloseButton"; +import { useOnKeydown } from "../../hooks/useOnKeydown"; import { ModalTitle } from "./ModalTitle"; import { ModalFooter } from "./ModalFooter"; import { ModalError } from "./ModalError"; @@ -26,6 +27,15 @@ export const BaseModal = ({ isOpen, onClose, children, modalId }: BaseModalProps } }, [isOpen]); + useOnKeydown( + "Escape", + useCallback(() => { + if (openedModal) { + onClose(); + } + }, [onClose, openedModal]), + ); + return ( { + return ( + + ); +}; diff --git a/apps/app/src/components/CloseButton/CloseButton.tsx b/apps/app/src/components/CloseButton/CloseButton.tsx index 2674c1ff..61fbf4a0 100644 --- a/apps/app/src/components/CloseButton/CloseButton.tsx +++ b/apps/app/src/components/CloseButton/CloseButton.tsx @@ -1,7 +1,10 @@ import { twMerge } from "tailwind-merge"; import { ButtonHTMLAttributes } from "react"; -export const CloseButton = ({ className, ...props }: ButtonHTMLAttributes) => ( +export const CloseButton = ({ + className, + ...props +}: ButtonHTMLAttributes & { "aria-label": string }) => ( ); diff --git a/apps/app/src/components/CtaHeader/CtaHeader.tsx b/apps/app/src/components/CtaHeader/CtaHeader.tsx index 725d7df9..501680a9 100644 --- a/apps/app/src/components/CtaHeader/CtaHeader.tsx +++ b/apps/app/src/components/CtaHeader/CtaHeader.tsx @@ -13,21 +13,27 @@ type CtaHeaderActiveLinkProps = Readonly<{ const CtaHeaderActiveLink = (props: CtaHeaderActiveLinkProps) => ( ); export const CtaHeader = () => (
- -