diff --git a/frontend/src/features/app/components/SideMenu.scss b/frontend/src/features/app/components/SideMenu.scss index 7322a9db..c29f8b56 100644 --- a/frontend/src/features/app/components/SideMenu.scss +++ b/frontend/src/features/app/components/SideMenu.scss @@ -20,6 +20,20 @@ outline: 2px solid var(--cds-focus); outline-offset: -2px; } + + &:disabled, + &[aria-disabled="true"] { + color: var(--cds-text-disabled); + cursor: not-allowed; + + svg { + color: var(--cds-icon-disabled); + } + + &:hover { + background: transparent; + } + } } // ── Backdrop overlay ── diff --git a/frontend/src/features/app/components/SideMenu.tsx b/frontend/src/features/app/components/SideMenu.tsx index c7522dc1..7475206c 100644 --- a/frontend/src/features/app/components/SideMenu.tsx +++ b/frontend/src/features/app/components/SideMenu.tsx @@ -31,6 +31,7 @@ import { chatbotRepository } from "@/infrastructure/api/repositories"; import { ChatHistoryPanel } from "@/features/chatbot/components/chat-ui/ChatHistoryPanel"; import { useOptionalContest } from "@/features/contest/contexts"; import { getClassroomContestDashboardPath } from "@/features/contest/domain/contestRoutePolicy"; +import { shouldLockContestWorkspaceNavigation } from "@/features/contest/domain/contestRuntimePolicy"; import { useContestRuntimeMode } from "@/features/contest/hooks"; import { getContestTypeModule } from "@/features/contest/modules/registry"; import type { AdminPanelId } from "@/features/contest/modules/types"; @@ -121,6 +122,8 @@ export const SideMenu: React.FC = ({ const effectiveContestForNav = contextContestForNav ?? contestForNav; const { isRuntime } = useContestRuntimeMode(); + const hideClassroomBack = + isRuntime || shouldLockContestWorkspaceNavigation(effectiveContestForNav); const inContestIdle = !!contestMatch && !contestAdminContext && !isRuntime; const inContestRuntime = !!contestMatch && isRuntime; @@ -357,6 +360,7 @@ export const SideMenu: React.FC = ({ classroomId={contestMatch.classroomId} contestId={contestMatch.contestId} compact={compact} + hideClassroomBack={hideClassroomBack} /> ) : ( <> diff --git a/frontend/src/features/app/components/SideMenuContestIdleSection.tsx b/frontend/src/features/app/components/SideMenuContestIdleSection.tsx index ed11633b..4e087678 100644 --- a/frontend/src/features/app/components/SideMenuContestIdleSection.tsx +++ b/frontend/src/features/app/components/SideMenuContestIdleSection.tsx @@ -1,14 +1,19 @@ import { Link, useLocation } from "react-router-dom"; -import { Home, ArrowLeft } from "@carbon/icons-react"; +import { ArrowLeft, Home } from "@carbon/icons-react"; interface Props { classroomId: string; contestId: string; /** kept for API parity with the runtime section; styling is driven by the parent .side-menu--mini class */ compact?: boolean; + hideClassroomBack?: boolean; } -export const SideMenuContestIdleSection = ({ classroomId, contestId }: Props) => { +export const SideMenuContestIdleSection = ({ + classroomId, + contestId, + hideClassroomBack = false, +}: Props) => { const { pathname } = useLocation(); const dashboardPath = `/classrooms/${classroomId}/contest/${contestId}`; const isDashboard = pathname === dashboardPath; @@ -16,10 +21,12 @@ export const SideMenuContestIdleSection = ({ classroomId, contestId }: Props) => return (
- - - 返回教室 - + {!hideClassroomBack && ( + + + 返回教室 + + )} ) => { + if (contestNavigationReadOnly) return; + setOpenMenu((menu) => menu === kind ? null : kind); + }, [contestNavigationReadOnly]); return (
@@ -195,15 +204,15 @@ export function WorkspaceTopNav({ showSidebarControl }: WorkspaceTopNavProps) {
{effectiveOpenMenu === "classroom" ? (
@@ -233,17 +242,17 @@ export function WorkspaceTopNav({ showSidebarControl }: WorkspaceTopNavProps) {
{effectiveOpenMenu === "contest" ? (
@@ -268,14 +277,14 @@ export function WorkspaceTopNav({ showSidebarControl }: WorkspaceTopNavProps) {
{effectiveOpenMenu === "contestMode" ? (
diff --git a/frontend/src/features/contest/components/exam/ExamNavigator.tsx b/frontend/src/features/contest/components/exam/ExamNavigator.tsx index af86a2e5..5890eab0 100644 --- a/frontend/src/features/contest/components/exam/ExamNavigator.tsx +++ b/frontend/src/features/contest/components/exam/ExamNavigator.tsx @@ -1,4 +1,4 @@ -import { type FC, memo, useEffect, useRef } from "react"; +import { type ComponentType, type FC, memo, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import type { ExamItem } from "../../types/exam.types"; import { Home, SidePanelClose, SidePanelOpen } from "@carbon/icons-react"; @@ -21,6 +21,7 @@ interface ExamNavigatorProps { onSelect: (index: number) => void; collapsed?: boolean; overviewLabel?: string; + overviewIcon?: ComponentType<{ size?: number }>; onSelectOverview?: () => void; onToggleCollapse?: () => void; hideHeader?: boolean; @@ -34,6 +35,7 @@ export const ExamNavigator: FC = memo(({ onSelect, collapsed = false, overviewLabel, + overviewIcon: OverviewIcon = Home, onSelectOverview, onToggleCollapse, hideHeader = false, @@ -83,7 +85,7 @@ export const ExamNavigator: FC = memo(({ onClick={onSelectOverview} className={styles.miniOverviewItem} > - + )} {items.map((item, index) => { @@ -154,7 +156,7 @@ export const ExamNavigator: FC = memo(({ - + {overviewLabel ?? t("contestShell.backToContestHome", "返回競賽主頁")} diff --git a/frontend/src/features/contest/contexts/ContestRuntimeNavigatorContext.ts b/frontend/src/features/contest/contexts/ContestRuntimeNavigatorContext.ts new file mode 100644 index 00000000..23bacefa --- /dev/null +++ b/frontend/src/features/contest/contexts/ContestRuntimeNavigatorContext.ts @@ -0,0 +1 @@ +export { ContestRuntimeNavigatorProvider } from "./ContestRuntimeNavigatorProvider"; diff --git a/frontend/src/features/contest/contexts/ContestRuntimeNavigatorContext.tsx b/frontend/src/features/contest/contexts/ContestRuntimeNavigatorContext.tsx index 86b78091..23bacefa 100644 --- a/frontend/src/features/contest/contexts/ContestRuntimeNavigatorContext.tsx +++ b/frontend/src/features/contest/contexts/ContestRuntimeNavigatorContext.tsx @@ -1,29 +1 @@ -import { - useMemo, - useState, - type ReactNode, -} from "react"; - -import { - ContestRuntimeNavigatorContext, - type ContestRuntimeNavigatorState, -} from "./contestRuntimeNavigatorStore"; - -export function ContestRuntimeNavigatorProvider({ - children, -}: { - children: ReactNode; -}) { - const [navigator, setNavigator] = - useState(null); - const value = useMemo( - () => ({ navigator, setNavigator }), - [navigator], - ); - - return ( - - {children} - - ); -} +export { ContestRuntimeNavigatorProvider } from "./ContestRuntimeNavigatorProvider"; diff --git a/frontend/src/features/contest/contexts/ContestRuntimeNavigatorProvider.ts b/frontend/src/features/contest/contexts/ContestRuntimeNavigatorProvider.ts new file mode 100644 index 00000000..4b6a12ea --- /dev/null +++ b/frontend/src/features/contest/contexts/ContestRuntimeNavigatorProvider.ts @@ -0,0 +1,25 @@ +import { createElement, useMemo, useState, type ReactNode } from "react"; + +import { + ContestRuntimeNavigatorContext, + type ContestRuntimeNavigatorState, +} from "./contestRuntimeNavigatorStore"; + +export function ContestRuntimeNavigatorProvider({ + children, +}: { + children: ReactNode; +}) { + const [navigator, setNavigator] = + useState(null); + const value = useMemo( + () => ({ navigator, setNavigator }), + [navigator], + ); + + return createElement( + ContestRuntimeNavigatorContext.Provider, + { value }, + children, + ); +} diff --git a/frontend/src/features/contest/domain/contestRuntimePolicy.test.ts b/frontend/src/features/contest/domain/contestRuntimePolicy.test.ts index ce62a295..a86ba911 100644 --- a/frontend/src/features/contest/domain/contestRuntimePolicy.test.ts +++ b/frontend/src/features/contest/domain/contestRuntimePolicy.test.ts @@ -6,6 +6,7 @@ import { isContestParticipant, isExamMonitoringActive, isStrictSubmittedBeforeEnd, + shouldLockContestWorkspaceNavigation, shouldForceEndExamOnExit, shouldWarnOnExit, } from "./contestRuntimePolicy"; @@ -119,6 +120,34 @@ describe("contestRuntimePolicy", () => { ).toBe(false); }); + it("locks workspace navigation only while a joined exam is actively running", () => { + expect( + shouldLockContestWorkspaceNavigation( + createContest({ hasJoined: true, examStatus: "in_progress" }), + ), + ).toBe(true); + expect( + shouldLockContestWorkspaceNavigation( + createContest({ hasJoined: true, examStatus: "paused" }), + ), + ).toBe(true); + expect( + shouldLockContestWorkspaceNavigation( + createContest({ hasJoined: true, examStatus: "locked" }), + ), + ).toBe(true); + expect( + shouldLockContestWorkspaceNavigation( + createContest({ hasJoined: false, examStatus: "in_progress" }), + ), + ).toBe(false); + expect( + shouldLockContestWorkspaceNavigation( + createContest({ hasJoined: true, examStatus: "submitted" }), + ), + ).toBe(false); + }); + it("blocks strict submitted contests before end time", () => { const nowMs = Date.parse("2026-03-16T10:00:00.000Z"); const strictSubmitted = createContest({ diff --git a/frontend/src/features/contest/domain/contestRuntimePolicy.ts b/frontend/src/features/contest/domain/contestRuntimePolicy.ts index 737dc270..9b4d8af0 100644 --- a/frontend/src/features/contest/domain/contestRuntimePolicy.ts +++ b/frontend/src/features/contest/domain/contestRuntimePolicy.ts @@ -3,6 +3,10 @@ import type { ContestDetail, ContestStatus, ContestType, ExamStatusType } from " type ContestTypeTarget = Pick | null | undefined; type ParticipantTarget = Pick | null | undefined; type ExamStatusTarget = Pick | null | undefined; +type WorkspaceNavigationTarget = + | Pick + | null + | undefined; type TabTarget = | Pick< ContestDetail, @@ -47,6 +51,12 @@ const EXIT_WARNING_STATUSES = new Set([ "locked", ]); +const WORKSPACE_NAVIGATION_LOCK_STATUSES = new Set([ + "in_progress", + "paused", + "locked", +]); + const isExamStatusIn = ( status: ExamStatusType | undefined, allowed: Set, @@ -58,6 +68,12 @@ export const isContestParticipant = (contest: ParticipantTarget): boolean => export const hasStartedExam = (contest: ExamStatusTarget): boolean => !!contest && isExamStatusIn(contest.examStatus, EXAM_STARTED_STATUSES); +export const shouldLockContestWorkspaceNavigation = ( + contest: WorkspaceNavigationTarget, +): boolean => + !!contest?.hasJoined && + isExamStatusIn(contest.examStatus, WORKSPACE_NAVIGATION_LOCK_STATUSES); + const parseEndTimeMs = (endTime: string | undefined): number | null => { if (!endTime) return null; const parsed = Date.parse(endTime);