Skip to content
Open
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
4 changes: 2 additions & 2 deletions example.env
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# The backend of SciEdu
VITE_BACKEND_BASE_URL="https://api.sciedu.sdc.nycu.club"
VITE_BACKEND_BASE_URL="https://dev.sciedu.sdc.nycu.club"

# This frontend should only serve as either an educational
# content provider (edu mode), as a standalone llm (llm
# mode) or enable both during development (dev mode)
# The following feature should be one of edu/llm/dev
VITE_APP_MODE=
VITE_APP_MODE=dev

# Local-only Experimenter Admin preview. This bypass is ignored by production
# builds even when set to true.
Expand Down
22 changes: 17 additions & 5 deletions src/features/admin/components/AddParticipantModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type Props = {
onClose: () => void;
};

const MAX_PARTICIPANTS_PER_REQUEST = 100;

export default function AddParticipantModal({ experimentId, onClose }: Props) {
const queryClient = useQueryClient();
const [query, setQuery] = useState("");
Expand Down Expand Up @@ -59,7 +61,7 @@ export default function AddParticipantModal({ experimentId, onClose }: Props) {
setSelectedIds((previous) => {
const next = new Set(previous);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
else if (next.size < MAX_PARTICIPANTS_PER_REQUEST) next.add(userId);
return next;
});
};
Expand All @@ -84,6 +86,7 @@ export default function AddParticipantModal({ experimentId, onClose }: Props) {
autoFocus
type="search"
placeholder="搜尋姓名或郵件"
maxLength={200}
aria-label="搜尋可加入的學生"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
Expand All @@ -104,7 +107,12 @@ export default function AddParticipantModal({ experimentId, onClose }: Props) {
</Text>
)}
{candidatesQuery.data?.map((candidate) => {
const isAvailable = !candidate.isAssigned;
const isSelected = selectedIds.has(candidate.user.id);
const hasReachedLimit =
selectedIds.size >= MAX_PARTICIPANTS_PER_REQUEST &&
!isSelected;
const isAvailable =
!candidate.isAssigned && !hasReachedLimit;
return (
<label
key={candidate.user.id}
Expand All @@ -113,7 +121,7 @@ export default function AddParticipantModal({ experimentId, onClose }: Props) {
}`}
>
<Checkbox
checked={selectedIds.has(candidate.user.id)}
checked={isSelected}
disabled={!isAvailable}
onChange={() =>
toggleCandidate(candidate.user.id)
Expand Down Expand Up @@ -141,7 +149,11 @@ export default function AddParticipantModal({ experimentId, onClose }: Props) {
: styles.conflict
}
>
{isAvailable ? "可加入" : "已加入本實驗"}
{candidate.isAssigned
? "已加入本實驗"
: hasReachedLimit
? "已達單次上限"
: "可加入"}
</Text>
</label>
);
Expand All @@ -160,7 +172,7 @@ export default function AddParticipantModal({ experimentId, onClose }: Props) {
className={styles.mantineModalFooter}
>
<Text size="sm" c="dimmed">
已選擇 {selectedIds.size} 位學生
已選擇 {selectedIds.size} 位學生(單次最多 100 位)
</Text>
<Group gap="sm">
<Button variant="default" onClick={onClose}>
Expand Down
7 changes: 5 additions & 2 deletions src/features/admin/components/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Search,
Users,
} from "lucide-react";
import { useNavigate } from "react-router";

import { roleLabels } from "../formatters";
import type { User } from "../types";
Expand All @@ -25,6 +26,8 @@ export default function AdminSidebar({
activeSection,
onNavigate,
}: Props) {
const navigate = useNavigate();

return (
<aside className={styles.sidebar}>
<div className={styles.sidebarBrand}>
Expand All @@ -41,9 +44,9 @@ export default function AdminSidebar({
<Search aria-hidden="true" />
總覽
</UnstyledButton>
<UnstyledButton disabled>
<UnstyledButton onClick={() => navigate("/admin/experiments")}>
<FlaskConical aria-hidden="true" />
實驗場次(未開放)
實驗管理
</UnstyledButton>
<UnstyledButton disabled>
<BookOpen aria-hidden="true" />
Expand Down
109 changes: 109 additions & 0 deletions src/features/admin/components/ExperimentAdminShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { ReactNode } from "react";
import { Avatar, UnstyledButton } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
CheckCircle2,
FlaskConical,
GraduationCap,
Search,
Users,
} from "lucide-react";
import { useNavigate } from "react-router";

import { roleLabels } from "../formatters";
import { fetchCurrentUser } from "../services/adminRepository";
import styles from "../pages/ExperimentAdmin.module.css";

type Section = "overview" | "experiments" | "people";

export default function ExperimentAdminShell({
activeSection,
children,
}: {
activeSection: Section;
children: ReactNode;
}) {
const navigate = useNavigate();
const currentUserQuery = useQuery({
queryKey: ["users", "me"],
queryFn: fetchCurrentUser,
staleTime: 5 * 60 * 1000,
});

if (currentUserQuery.isPending) {
return <div className={styles.pageStatus}>載入研究管理後台中⋯</div>;
}
if (currentUserQuery.isError) {
return <div className={styles.pageStatus}>研究管理後台載入失敗</div>;
}

const currentUser = currentUserQuery.data;
return (
<div className={styles.shell}>
<aside className={styles.sidebar}>
<div className={styles.brand}>
<GraduationCap aria-hidden="true" />
<span>研究管理後台</span>
</div>
<nav className={styles.nav} aria-label="研究管理後台主選單">
<UnstyledButton
className={
activeSection === "overview"
? styles.navActive
: undefined
}
onClick={() => navigate("/admin")}
>
<Search aria-hidden="true" />
總覽
</UnstyledButton>
<UnstyledButton
className={
activeSection === "experiments"
? styles.navActive
: undefined
}
onClick={() => navigate("/admin/experiments")}
>
<FlaskConical aria-hidden="true" />
實驗管理
</UnstyledButton>
<UnstyledButton disabled>
<BookOpen aria-hidden="true" />
教材管理(未開放)
</UnstyledButton>
<UnstyledButton
className={
activeSection === "people"
? styles.navActive
: undefined
}
onClick={() => navigate("/admin?section=people")}
>
<Users aria-hidden="true" />
人員管理
</UnstyledButton>
<UnstyledButton disabled>
<CheckCircle2 aria-hidden="true" />
作答紀錄(未開放)
</UnstyledButton>
</nav>
<div className={styles.profile}>
<Avatar color="brandTeal" size={52}>
{currentUser.name.trim().slice(0, 1)}
</Avatar>
<span>
<strong>{currentUser.name}</strong>
<small>
{currentUser.roles
.map((role) => roleLabels[role])
.join("、")}
</small>
</span>
</div>
</aside>
<main className={styles.main}>{children}</main>
</div>
);
}
20 changes: 10 additions & 10 deletions src/features/admin/data/demoAdminData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ const updatedAt = "2026-07-01T09:00:00+08:00";

export const demoCurrentUser: User = {
id: "00000000-0000-4000-8000-000000000013",
name: "佘曉青",
email: "hsiao-ching.she@nycu.edu.tw",
name: "王小明",
email: "xiaoming.wang@nycu.edu.tw",
roles: ["EXPERIMENTER"],
createdAt,
updatedAt,
Expand Down Expand Up @@ -81,8 +81,8 @@ export const demoCourses: ExperimentCourseAssignment[] = [
course: {
id: "10000000-0000-4000-8000-000000000001",
code: "GEN-01",
title: "豌豆-種皮形狀單因子遺傳與表型觀察",
description: "單因子遺傳與表型觀察",
title: "遺傳機制推理學習",
description: "生物學・3 頁",
status: "PUBLISHED",
createdAt,
updatedAt,
Expand All @@ -93,8 +93,8 @@ export const demoCourses: ExperimentCourseAssignment[] = [
course: {
id: "10000000-0000-4000-8000-000000000002",
code: "GEN-02",
title: "豌豆-莖高",
description: "顯性與隱性性狀分析",
title: "孟德爾遺傳與機率",
description: "生物學・4 頁",
status: "PUBLISHED",
createdAt,
updatedAt,
Expand All @@ -105,8 +105,8 @@ export const demoCourses: ExperimentCourseAssignment[] = [
course: {
id: "10000000-0000-4000-8000-000000000003",
code: "GEN-03",
title: "果蠅-眼色",
description: "伴性遺傳推理活動",
title: "基因表現與環境",
description: "生物學・3 頁",
status: "PUBLISHED",
createdAt,
updatedAt,
Expand All @@ -117,8 +117,8 @@ export const demoCourses: ExperimentCourseAssignment[] = [
course: {
id: "10000000-0000-4000-8000-000000000004",
code: "GEN-04",
title: "金魚草-花色",
description: "不完全顯性遺傳",
title: "DNA 萃取實驗",
description: "生物學・2 頁",
status: "DRAFT",
createdAt,
updatedAt,
Expand Down
10 changes: 10 additions & 0 deletions src/features/admin/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,13 @@ export function formatDateTime(value: string) {
hour12: false,
}).format(date);
}

export function formatShortDateTime(value: string) {
return new Intl.DateTimeFormat("zh-TW", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
11 changes: 10 additions & 1 deletion src/features/admin/pages/AdminDashboardPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
--admin-ink: #004038;
--admin-sidebar: #004d43;
--admin-sidebar-active: #0e6258;
--admin-sidebar-hover: #07584f;
--admin-canvas: #f0f6f4;
--admin-border: #d4d4d4;
min-height: 100vh;
Expand Down Expand Up @@ -77,11 +78,19 @@
text-align: left;
}

.sidebarNav button:hover:not(:disabled),
.sidebarNav .navActive {
background: var(--admin-sidebar-active);
}

.sidebarNav button:hover:not(:disabled):not(.navActive) {
background: var(--admin-sidebar-hover);
}

.sidebarNav button:focus-visible {
outline: 2px solid #77d5c6;
outline-offset: 2px;
}

.sidebarNav button:disabled {
cursor: not-allowed;
opacity: 0.72;
Expand Down
10 changes: 6 additions & 4 deletions src/features/admin/pages/AdminDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Search,
} from "lucide-react";
import { toast } from "sonner";
import { Navigate, useSearchParams } from "react-router";

import { useDocumentTitle } from "../../../shared/hooks";
import AddParticipantModal from "../components/AddParticipantModal";
Expand All @@ -44,9 +45,10 @@ const PAGE_SIZE = 10;
export default function AdminDashboardPage() {
useDocumentTitle("研究管理後台");
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();

const [activeSection, setActiveSection] =
useState<AdminSection>("overview");
const activeSection: AdminSection =
searchParams.get("section") === "people" ? "people" : "overview";
const [selectedExperimentId, setSelectedExperimentId] = useState("");
const [tableView, setTableView] = useState<TableView>("participants");
const [query, setQuery] = useState("");
Expand Down Expand Up @@ -164,7 +166,7 @@ export default function AdminDashboardPage() {
};

const switchSection = (section: AdminSection) => {
setActiveSection(section);
setSearchParams(section === "people" ? { section: "people" } : {});
resetTable();
};

Expand All @@ -175,7 +177,7 @@ export default function AdminDashboardPage() {
return <div className={styles.pageStatus}>實驗總覽載入失敗</div>;
}
if (experiments.length === 0) {
return <div className={styles.pageStatus}>目前沒有可管理的實驗</div>;
return <Navigate replace to="/admin/experiments" />;
}
if (experimentQuery.isError) {
return <div className={styles.pageStatus}>實驗資料載入失敗</div>;
Expand Down
Loading
Loading