Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions frontend/src/features/app/components/SideMenu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/features/app/components/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -121,6 +122,8 @@ export const SideMenu: React.FC<SideMenuProps> = ({
const effectiveContestForNav = contextContestForNav ?? contestForNav;

const { isRuntime } = useContestRuntimeMode();
const hideClassroomBack =
isRuntime || shouldLockContestWorkspaceNavigation(effectiveContestForNav);

const inContestIdle = !!contestMatch && !contestAdminContext && !isRuntime;
const inContestRuntime = !!contestMatch && isRuntime;
Expand Down Expand Up @@ -357,6 +360,7 @@ export const SideMenu: React.FC<SideMenuProps> = ({
classroomId={contestMatch.classroomId}
contestId={contestMatch.contestId}
compact={compact}
hideClassroomBack={hideClassroomBack}
/>
) : (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
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;
const classroomPath = `/classrooms/${classroomId}`;

return (
<div className="side-menu__section">
<Link to={classroomPath} className="side-menu__link">
<ArrowLeft size={20} />
<span>返回教室</span>
</Link>
{!hideClassroomBack && (
<Link to={classroomPath} className="side-menu__link">
<ArrowLeft size={20} />
<span>返回教室</span>
</Link>
)}
<Link
to={dashboardPath}
className={`side-menu__link${isDashboard ? " side-menu__link--active" : ""}`}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useNavigate } from "react-router-dom";
import { Checkmark, CircleDash, IncompleteCancel } from "@carbon/icons-react";
import { ArrowLeft, Checkmark, CircleDash, IncompleteCancel } from "@carbon/icons-react";
import type { ContestProblemSummary } from "@/core/entities/contest.entity";
import type { SubmissionStatus } from "@/core/entities/submission.entity";
import { ExamNavigator } from "@/features/contest/components/exam/ExamNavigator";
Expand Down Expand Up @@ -50,6 +50,7 @@ export const SideMenuContestRuntimeSection = ({
markedIds={runtimeNavigator.markedIds}
collapsed={compact}
overviewLabel={runtimeNavigator.overviewLabel}
overviewIcon={ArrowLeft}
onSelectOverview={runtimeNavigator.onSelectOverview}
onSelect={runtimeNavigator.onSelect}
hideHeader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,9 @@
cursor: not-allowed;
opacity: 0.5;
}

.contextLinkReadOnly,
.contextLinkReadOnly:hover {
background: transparent;
cursor: default;
}
41 changes: 25 additions & 16 deletions frontend/src/features/app/components/workspace/WorkspaceTopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import type { BoundContest, Classroom, ClassroomDetail } from "@/core/entities/c
import { getClassroom, getClassrooms } from "@/infrastructure/api/repositories/classroom.repository";
import { getClassroomIcon } from "@/features/classroom/constants/classroomIcons";
import { getClassroomContestAdminPath, getClassroomContestDashboardPath } from "@/features/contest/domain/contestRoutePolicy";
import { shouldLockContestWorkspaceNavigation } from "@/features/contest/domain/contestRuntimePolicy";
import { usePageHeaderActionsSlot } from "@/features/app/contexts/PageHeaderActionsContext";
import { useWorkspace } from "@/features/app/contexts/WorkspaceContext";
import { UserMenu } from "@/features/app/components/UserMenu";
import { useContestRuntimeMode } from "@/features/contest/hooks";
import { useContest } from "@/features/contest/contexts/ContestContext";
import { useOptionalContest } from "@/features/contest/contexts";
import { useContestTimers } from "@/features/contest/hooks/useContestTimers";
import { ExamModeMonitorModal } from "@/features/contest/components/modals/ExamModeMonitorModal";
import { useExamMonitoringStatus } from "@/features/contest/contexts/ExamMonitoringStatusContext";
Expand Down Expand Up @@ -167,7 +169,14 @@ export function WorkspaceTopNav({ showSidebarControl }: WorkspaceTopNavProps) {
}, [classroomId, contestRouteId, navigate]);

const { isRuntime } = useContestRuntimeMode();
const effectiveOpenMenu = isRuntime ? null : openMenu;
const contestData = useOptionalContest();
const contestNavigationReadOnly =
isRuntime || shouldLockContestWorkspaceNavigation(contestData?.contest);
const effectiveOpenMenu = contestNavigationReadOnly ? null : openMenu;
const toggleMenu = useCallback((kind: Exclude<MenuKind, null>) => {
if (contestNavigationReadOnly) return;
setOpenMenu((menu) => menu === kind ? null : kind);
}, [contestNavigationReadOnly]);
Comment on lines 171 to +179

return (
<header className={styles.root}>
Expand Down Expand Up @@ -195,15 +204,15 @@ export function WorkspaceTopNav({ showSidebarControl }: WorkspaceTopNavProps) {
<div className={styles.menuAnchor}>
<button
type="button"
className={styles.contextLink}
aria-haspopup="menu"
className={`${styles.contextLink}${contestNavigationReadOnly ? ` ${styles.contextLinkReadOnly}` : ""}`}
aria-haspopup={contestNavigationReadOnly ? undefined : "menu"}
aria-expanded={effectiveOpenMenu === "classroom"}
onClick={() => setOpenMenu((menu) => menu === "classroom" ? null : "classroom")}
disabled={isRuntime}
aria-disabled={contestNavigationReadOnly ? "true" : undefined}
onClick={() => toggleMenu("classroom")}
>
{createElement(getClassroomIcon(currentClassroom.icon), { size: 18 })}
<span>{currentClassroom.name}</span>
{!isRuntime ? <ChevronDown size={14} /> : null}
{!contestNavigationReadOnly ? <ChevronDown size={14} /> : null}
</button>
{effectiveOpenMenu === "classroom" ? (
<div className={styles.menu} role="menu">
Expand Down Expand Up @@ -233,17 +242,17 @@ export function WorkspaceTopNav({ showSidebarControl }: WorkspaceTopNavProps) {
<div className={styles.menuAnchor}>
<button
type="button"
className={styles.contextLink}
aria-haspopup="menu"
className={`${styles.contextLink}${contestNavigationReadOnly ? ` ${styles.contextLinkReadOnly}` : ""}`}
aria-haspopup={contestNavigationReadOnly ? undefined : "menu"}
aria-expanded={effectiveOpenMenu === "contest"}
onClick={() => setOpenMenu((menu) => menu === "contest" ? null : "contest")}
disabled={isRuntime}
aria-disabled={contestNavigationReadOnly ? "true" : undefined}
onClick={() => toggleMenu("contest")}
>
<span>
{currentContest?.contestName ??
t("workspaceTopNav.contestFallback", "Contest")}
</span>
{!isRuntime ? <ChevronDown size={14} /> : null}
{!contestNavigationReadOnly ? <ChevronDown size={14} /> : null}
</button>
{effectiveOpenMenu === "contest" ? (
<div className={styles.menu} role="menu">
Expand All @@ -268,14 +277,14 @@ export function WorkspaceTopNav({ showSidebarControl }: WorkspaceTopNavProps) {
<div className={styles.menuAnchor}>
<button
type="button"
className={styles.contextLink}
aria-haspopup="menu"
className={`${styles.contextLink}${contestNavigationReadOnly ? ` ${styles.contextLinkReadOnly}` : ""}`}
aria-haspopup={contestNavigationReadOnly ? undefined : "menu"}
aria-expanded={effectiveOpenMenu === "contestMode"}
onClick={() => setOpenMenu((menu) => menu === "contestMode" ? null : "contestMode")}
disabled={isRuntime}
aria-disabled={contestNavigationReadOnly ? "true" : undefined}
onClick={() => toggleMenu("contestMode")}
>
<span>{t("workspaceTopNav.adminConsole", "管理後台")}</span>
{!isRuntime ? <ChevronDown size={14} /> : null}
{!contestNavigationReadOnly ? <ChevronDown size={14} /> : null}
</button>
{effectiveOpenMenu === "contestMode" ? (
<div className={styles.menu} role="menu">
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +21,7 @@ interface ExamNavigatorProps {
onSelect: (index: number) => void;
collapsed?: boolean;
overviewLabel?: string;
overviewIcon?: ComponentType<{ size?: number }>;
onSelectOverview?: () => void;
onToggleCollapse?: () => void;
hideHeader?: boolean;
Expand All @@ -34,6 +35,7 @@ export const ExamNavigator: FC<ExamNavigatorProps> = memo(({
onSelect,
collapsed = false,
overviewLabel,
overviewIcon: OverviewIcon = Home,
onSelectOverview,
onToggleCollapse,
hideHeader = false,
Expand Down Expand Up @@ -83,7 +85,7 @@ export const ExamNavigator: FC<ExamNavigatorProps> = memo(({
onClick={onSelectOverview}
className={styles.miniOverviewItem}
>
<Home size={18} />
<OverviewIcon size={18} />
</ListItem>
)}
{items.map((item, index) => {
Expand Down Expand Up @@ -154,7 +156,7 @@ export const ExamNavigator: FC<ExamNavigatorProps> = memo(({
<ListItem onClick={onSelectOverview} className={styles.overviewItem}>
<ListItemContent>
<ListItemTitle className={styles.overviewTitle}>
<Home size={18} />
<OverviewIcon size={18} />
<span>
{overviewLabel ??
t("contestShell.backToContestHome", "返回競賽主頁")}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ContestRuntimeNavigatorProvider } from "./ContestRuntimeNavigatorProvider";
Original file line number Diff line number Diff line change
@@ -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<ContestRuntimeNavigatorState | null>(null);
const value = useMemo(
() => ({ navigator, setNavigator }),
[navigator],
);

return (
<ContestRuntimeNavigatorContext.Provider value={value}>
{children}
</ContestRuntimeNavigatorContext.Provider>
);
}
export { ContestRuntimeNavigatorProvider } from "./ContestRuntimeNavigatorProvider";
Original file line number Diff line number Diff line change
@@ -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<ContestRuntimeNavigatorState | null>(null);
const value = useMemo(
() => ({ navigator, setNavigator }),
[navigator],
);

return createElement(
ContestRuntimeNavigatorContext.Provider,
{ value },
children,
);
}
29 changes: 29 additions & 0 deletions frontend/src/features/contest/domain/contestRuntimePolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isContestParticipant,
isExamMonitoringActive,
isStrictSubmittedBeforeEnd,
shouldLockContestWorkspaceNavigation,
shouldForceEndExamOnExit,
shouldWarnOnExit,
} from "./contestRuntimePolicy";
Expand Down Expand Up @@ -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);
Comment on lines +123 to +128
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({
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/features/contest/domain/contestRuntimePolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type { ContestDetail, ContestStatus, ContestType, ExamStatusType } from "
type ContestTypeTarget = Pick<ContestDetail, "contestType"> | null | undefined;
type ParticipantTarget = Pick<ContestDetail, "hasJoined"> | null | undefined;
type ExamStatusTarget = Pick<ContestDetail, "examStatus"> | null | undefined;
type WorkspaceNavigationTarget =
| Pick<ContestDetail, "examStatus" | "hasJoined">
| null
| undefined;
type TabTarget =
| Pick<
ContestDetail,
Expand Down Expand Up @@ -47,6 +51,12 @@ const EXIT_WARNING_STATUSES = new Set<ExamStatusType>([
"locked",
]);

const WORKSPACE_NAVIGATION_LOCK_STATUSES = new Set<ExamStatusType>([
"in_progress",
"paused",
"locked",
]);

const isExamStatusIn = (
status: ExamStatusType | undefined,
allowed: Set<ExamStatusType>,
Expand All @@ -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);
Expand Down
Loading