diff --git a/package.json b/package.json index e48754cf..1258b5a3 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@willbooster/shared-lib": "5.2.4", "@willbooster/shared-lib-react": "3.2.9", "@willbooster/wb": "8.0.2", + "at-decorators": "2.1.0", "build-ts": "13.1.8", "dayjs": "1.11.13", "dotenv-cli": "7.4.2", diff --git a/src/app/(withAuth)/admin/statistics/page.tsx b/src/app/(withAuth)/admin/statistics/page.tsx index 954abd95..a7dea115 100644 --- a/src/app/(withAuth)/admin/statistics/page.tsx +++ b/src/app/(withAuth)/admin/statistics/page.tsx @@ -2,7 +2,7 @@ import type { NextPage } from 'next'; import { logger } from '../../../../infrastructures/pino'; import { prisma } from '../../../../infrastructures/prisma'; -import { Box, Heading, VStack, Table, Thead, Tbody, Tr, Th, Td } from '../../../../infrastructures/useClient/chakra'; +import { Box, Heading, Table, Tbody, Td, Th, Thead, Tr, VStack } from '../../../../infrastructures/useClient/chakra'; import type { CourseId } from '../../../../problems/problemData'; import { courseIdToLectureIndexToProblemIds } from '../../../../problems/problemData'; import { dayjs } from '../../../utils/dayjs'; diff --git a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/pageOnClient.tsx b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/pageOnClient.tsx index 5ad3e3c4..5fe1bdaf 100644 --- a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/pageOnClient.tsx +++ b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/pageOnClient.tsx @@ -1,10 +1,12 @@ 'use client'; import type { ProblemSession, ProblemSubmission } from '@prisma/client'; +import { useLocalStorage } from '@willbooster/shared-lib-react'; import NextLink from 'next/link'; -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { MdCheckCircle, MdCheckCircleOutline, MdOutlineVerified, MdVerified } from 'react-icons/md'; +import { useAuthContextSelector } from '../../../../../../contexts/AuthContext'; import { Box, Card, @@ -50,6 +52,15 @@ export const Lecture: React.FC = (props) => { const completedProblemCount = lectureProblemIds.filter((problemId) => completedProblemIdSet.has(problemId)).length; const isLessonCompleted = completedProblemCount >= lectureProblemIds.length; + const currentUserId = useAuthContextSelector((c) => c.currentUserId); + const [, setIsOpened] = useLocalStorage( + `trace-dojo.${props.params.courseId}.${props.lectureIndex}.${currentUserId}`, + false + ); + useEffect(() => { + setIsOpened(true); + }, [setIsOpened]); + return ( diff --git a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/page.tsx b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/page.tsx index f687eca0..d51e5ab2 100644 --- a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/page.tsx +++ b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/page.tsx @@ -1,5 +1,4 @@ import type { NextPage } from 'next'; -import SuperTokensNode from 'supertokens-node'; import { logger } from '../../../../../../../../infrastructures/pino'; import { prisma } from '../../../../../../../../infrastructures/prisma'; @@ -14,8 +13,6 @@ type Props = { const ProblemPage: NextPage = async (props) => { const session = await getNonNullableSessionOnServer(); - const superTokensUser = session && (await SuperTokensNode.getUser(session.superTokensUserId)); - const isAdmin = superTokensUser?.emails[0]?.endsWith('@internet.ac.jp'); let incompleteProblemSession = await prisma.problemSession.findFirst({ where: { @@ -45,7 +42,6 @@ const ProblemPage: NextPage = async (props) => { return ( diff --git a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/pageOnClient.tsx b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/pageOnClient.tsx index 8b791082..d5759f5e 100644 --- a/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/pageOnClient.tsx +++ b/src/app/(withAuth)/courses/[courseId]/lectures/[lectureId]/problems/[problemId]/pageOnClient.tsx @@ -11,6 +11,7 @@ import { MAX_ACTIVE_DURATION_MS_AFTER_LAST_EVENT, MIN_INTERVAL_MS_OF_ACTIVE_EVENTS, } from '../../../../../../../../constants'; +import { useAuthContextSelector } from '../../../../../../../../contexts/AuthContext'; import { backendTrpcReact } from '../../../../../../../../infrastructures/trpcBackend/client'; import { Button, @@ -29,12 +30,13 @@ import { ProblemBody } from './ProblmBody'; type Props = { initialProblemSession: ProblemSession; - isAdmin: boolean | undefined; params: { courseId: CourseId; lectureId: string; problemId: ProblemId }; userId: string; }; export const ProblemPageOnClient: React.FC = (props) => { + const isAdmin = useAuthContextSelector((c) => c.isAdmin); + const lectureIndex = courseIdToLectureIds[props.params.courseId].indexOf(props.params.lectureId); const problem = useMemo( () => @@ -127,7 +129,7 @@ export const ProblemPageOnClient: React.FC = (props) => { : 'ステップ実行モードで最初からやり直す'} - {props.isAdmin && ( + {isAdmin && ( - - - + const currentUserId = useAuthContextSelector((c) => c.currentUserId); + const [isOpened] = useLocalStorage(`trace-dojo.${courseId}.${lectureIndex}.${currentUserId}`, false); + const isDisabled = !isOpened && problemIds.every((problemId) => !currentUserStartedProblemIdSet.has(problemId)); + const url = isDisabled ? '#' : `${courseId}/lectures/${courseIdToLectureIds[courseId][lectureIndex]}`; - - - - 解答状況: {completedProblemCount}/{problemIds.length} 問 ( - {Math.round((completedProblemCount / problemIds.length) * 100)}%) - - - - ); - })} - - + return ( + + + + 第{lectureIndex + 1}回 + + + + + + + + + + + + 解答状況: {completedProblemCount}/{problemIds.length} 問 ( + {Math.round((completedProblemCount / problemIds.length) * 100)}%) + + + ); }; diff --git a/src/app/(withAuth)/layout.tsx b/src/app/(withAuth)/layout.tsx index aa1bd2b7..b8337b65 100644 --- a/src/app/(withAuth)/layout.tsx +++ b/src/app/(withAuth)/layout.tsx @@ -6,9 +6,10 @@ import { SessionAuthForNextJs } from '../../components/molecules/SessionAuthForN import { TryRefreshComponent } from '../../components/molecules/TryRefreshComponent'; import { DefaultFooter } from '../../components/organisms/DefaultFooter'; import { DefaultHeader } from '../../components/organisms/DefaultHeader'; +import { AuthContextProvider } from '../../contexts/AuthContext'; import { Container, Spinner } from '../../infrastructures/useClient/chakra'; import type { LayoutProps } from '../../types'; -import { getNullableSessionOnServer } from '../../utils/session'; +import { getEmailFromSession, getNullableSessionOnServer } from '../../utils/session'; const DefaultLayout: NextPage = async ({ children }) => { const { hasToken, session } = await getNullableSessionOnServer(); @@ -43,7 +44,12 @@ const DefaultLayout: NextPage = async ({ children }) => { }> - {children} + + {children} + diff --git a/src/app/(withAuth)/usage/page.tsx b/src/app/(withAuth)/usage/page.tsx index 130ce287..d59dc311 100644 --- a/src/app/(withAuth)/usage/page.tsx +++ b/src/app/(withAuth)/usage/page.tsx @@ -1,6 +1,6 @@ import type { NextPage } from 'next'; -import { Box, Heading, VStack, Image, Text, List, ListItem } from '../../../infrastructures/useClient/chakra'; +import { Box, Heading, Image, List, ListItem, Text, VStack } from '../../../infrastructures/useClient/chakra'; const CoursesPage: NextPage = async () => { return ( diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 00000000..7f241414 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,36 @@ +'use client'; + +import React from 'react'; +import { createContext, useContextSelector } from 'use-context-selector'; + +type Value = { + currentUserId: string; + currentEmail?: string; + isAdmin: boolean; +}; + +const Context = createContext(undefined as unknown as Value); + +export function useAuthContextSelector(selector: (value: Value) => Selected): Selected { + return useContextSelector(Context, selector); +} + +export type Props = { + children: React.ReactNode; + currentUserId: string; + currentEmail?: string; +}; + +export const AuthContextProvider: React.FC = (props) => { + return ( + + {props.children} + + ); +}; diff --git a/src/problems/instantiateProblem.ts b/src/problems/instantiateProblem.ts index f6b0c950..06397623 100644 --- a/src/problems/instantiateProblem.ts +++ b/src/problems/instantiateProblem.ts @@ -2,7 +2,7 @@ import { Random } from '../app/utils/random'; import type { LanguageId, ProblemId } from './problemData'; import { problemIdToLanguageIdToProgram } from './problemData'; -import { type TraceItemVariable, type TraceItem, traceProgram } from './traceProgram'; +import { type TraceItem, type TraceItemVariable, traceProgram } from './traceProgram'; export type InstantiatedProblem = { /** diff --git a/src/utils/session.ts b/src/utils/session.ts index dad10880..a4dafb49 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -1,3 +1,6 @@ +import { memoizeFactory } from 'at-decorators'; +import SuperTokensNode from 'supertokens-node'; + import { logger } from '../infrastructures/pino'; import { prisma } from '../infrastructures/prisma'; @@ -48,3 +51,19 @@ async function upsertUserToPrisma(id: string): Promise { }); logger.debug('User upserted: %o', user); } + +const memoizeForEmails = memoizeFactory({ + maxCachedArgsSize: Number.POSITIVE_INFINITY, + cacheDuration: 60 * 60 * 1000, +}); + +async function _getEmailFromSuperTokens(userId: string): Promise { + try { + const user = await SuperTokensNode.getUser(userId); + return user?.emails[0]; + } catch { + return undefined; + } +} + +export const getEmailFromSession = memoizeForEmails(_getEmailFromSuperTokens); diff --git a/yarn.lock b/yarn.lock index caf089a0..0fc63d81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4598,6 +4598,13 @@ __metadata: languageName: node linkType: hard +"at-decorators@npm:2.1.0": + version: 2.1.0 + resolution: "at-decorators@npm:2.1.0" + checksum: 10c0/2870952e8b99d91db6d3875f607ac35012b3937f585e52ce5640313e31d4e4417da68510594b457ffd26e51525e046c804101ad80f255681d594f1f8325e4433 + languageName: node + linkType: hard + "atomic-sleep@npm:^1.0.0": version: 1.0.0 resolution: "atomic-sleep@npm:1.0.0" @@ -13902,6 +13909,7 @@ __metadata: "@willbooster/shared-lib": "npm:5.2.4" "@willbooster/shared-lib-react": "npm:3.2.9" "@willbooster/wb": "npm:8.0.2" + at-decorators: "npm:2.1.0" build-ts: "npm:13.1.8" concurrently: "npm:9.0.1" conventional-changelog-conventionalcommits: "npm:8.0.0"