From f9853ec383502f104eba321fdf3311a078fea474 Mon Sep 17 00:00:00 2001
From: Youchen Jiang <1014308g@gmail.com>
Date: Thu, 8 Oct 2026 10:13:24 +0800
Subject: [PATCH 1/5] refactor(ui): extract modular components from HomePage
1. Extract HeroHeader, HomeModuleList, AnnouncementBar, HomeHeader, and HomeBody into src/components/home.
2. Reduce HomePage.tsx from 776 lines to 210 lines for better maintainability.
3. Preserve all Easter Egg, CTF challenge, and announcement interactions.
---
src/components/home/AnnouncementBar.tsx | 92 ++++
src/components/home/HeroHeader.tsx | 212 +++++++++
src/components/home/HomeBody.tsx | 42 ++
src/components/home/HomeHeader.tsx | 34 ++
src/components/home/HomeModuleList.tsx | 194 ++++++++
src/components/home/index.ts | 6 +
src/components/home/types.ts | 25 +
src/pages/HomePage.tsx | 602 +-----------------------
8 files changed, 622 insertions(+), 585 deletions(-)
create mode 100644 src/components/home/AnnouncementBar.tsx
create mode 100644 src/components/home/HeroHeader.tsx
create mode 100644 src/components/home/HomeBody.tsx
create mode 100644 src/components/home/HomeHeader.tsx
create mode 100644 src/components/home/HomeModuleList.tsx
create mode 100644 src/components/home/index.ts
create mode 100644 src/components/home/types.ts
diff --git a/src/components/home/AnnouncementBar.tsx b/src/components/home/AnnouncementBar.tsx
new file mode 100644
index 0000000..67c346a
--- /dev/null
+++ b/src/components/home/AnnouncementBar.tsx
@@ -0,0 +1,92 @@
+import React from "react";
+import { IonIcon } from "@ionic/react";
+import { chevronForwardOutline } from "ionicons/icons";
+import type { AnnouncementItem } from "../../services/announcement-api";
+
+export const getPriorityBorderColor = (priority: string): string => {
+ if (priority === "urgent") return "#f59e0b";
+ if (priority === "high") return "#f97316";
+ if (priority === "normal") return "#3b82f6";
+ return "var(--ncu-ink)";
+};
+
+export const AnnouncementBar = ({
+ announcements,
+ onOpen,
+}: Readonly<{
+ announcements: readonly AnnouncementItem[];
+ onOpen: () => void;
+}>) => {
+ const latest = announcements[0];
+ if (!latest) return null;
+
+ const priorityBorderColor = getPriorityBorderColor(latest.priority);
+
+ return (
+
+ );
+};
diff --git a/src/components/home/HeroHeader.tsx b/src/components/home/HeroHeader.tsx
new file mode 100644
index 0000000..076898a
--- /dev/null
+++ b/src/components/home/HeroHeader.tsx
@@ -0,0 +1,212 @@
+import React from "react";
+import type { ParticleData } from "./types";
+
+export const CTF_CONFIG = {
+ activeUrl: "https://im2026ctf.duckdns.org/",
+ scoreboardUrl: "https://im2026ctf.duckdns.org/scoreboard",
+ endTime: "2026-09-10T10:00:00+08:00",
+} as const;
+
+export const isCtfEnded = (): boolean => {
+ try {
+ return Date.now() > new Date(CTF_CONFIG.endTime).getTime();
+ } catch {
+ return false;
+ }
+};
+
+export const getStageIcon = (stage: number): string => {
+ if (stage <= 0) return "";
+ if (stage <= 4) return "⚡";
+ if (stage <= 8) return "🔓";
+ if (stage <= 12) return "🔥";
+ if (stage <= 16) return "🚨";
+ if (stage < 20) return "💥";
+ return isCtfEnded() ? "🏆" : "🚩";
+};
+
+export const getStageDropShadow = (stage: number): string => {
+ if (stage >= 17) return "drop-shadow(0 0 24px #10b981) drop-shadow(0 0 12px #38bdf8)";
+ if (stage >= 13) return "drop-shadow(0 0 20px rgba(239, 68, 68, 0.95))";
+ if (stage >= 9) return "drop-shadow(0 0 16px rgba(249, 115, 22, 0.9))";
+ if (stage >= 5) return "drop-shadow(0 0 12px rgba(168, 85, 247, 0.85))";
+ return "drop-shadow(0 0 8px rgba(56, 189, 248, 0.8))";
+};
+
+export const getStageRotation = (stage: number): number => {
+ if (stage >= 20) return 360;
+ return stage % 2 === 0 ? stage * 1.2 : -stage * 1.2;
+};
+
+export const getLogoStyle = (stage: number, isUnlocked: boolean): React.CSSProperties => {
+ const base: React.CSSProperties = {
+ width: 132,
+ height: 132,
+ marginBottom: 8,
+ border: "none",
+ overflow: "visible",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ background: "transparent",
+ cursor: "pointer",
+ userSelect: "none",
+ padding: 0,
+ transition: "transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.18s ease",
+ willChange: "transform",
+ backfaceVisibility: "hidden",
+ };
+
+ if (isUnlocked) {
+ return {
+ ...base,
+ filter: "drop-shadow(0 0 12px #10b981)",
+ animation: "unlockedPulse 3s ease-in-out infinite",
+ };
+ }
+
+ if (stage <= 0) {
+ return {
+ ...base,
+ filter: "drop-shadow(0 4px 12px rgba(27, 42, 74, 0.15))",
+ transform: "none",
+ };
+ }
+
+ const scale = 1.0 + (stage / 20) * 0.32;
+ const rotate = getStageRotation(stage);
+ const filter = getStageDropShadow(stage);
+
+ return {
+ ...base,
+ transform: `scale(${scale.toFixed(2)}) rotate(${rotate}deg)`,
+ filter,
+ };
+};
+
+export const NcuimLogoIcon = () => (
+
+);
+
+export const HeroHeader = ({
+ stage,
+ isUnlocked,
+ particles,
+ onLogoClick,
+}: Readonly<{
+ stage: number;
+ isUnlocked: boolean;
+ particles: readonly ParticleData[];
+ onLogoClick: () => void;
+}>) => (
+
+
+
+
+
+ {particles.map((p) => (
+
= 13 ? "rgba(15, 23, 42, 0.94)" : "rgba(15, 23, 42, 0.88)",
+ border: `1.5px solid ${p.stage >= 13 ? "#ef4444" : "#38bdf8"}`,
+ boxShadow: p.stage >= 13
+ ? "0 0 16px rgba(239, 68, 68, 0.6)"
+ : "0 0 14px rgba(56, 189, 248, 0.6)",
+ animation: `cyberGlowParticle ${p.duration}s ease-out forwards`,
+ willChange: "transform, opacity",
+ transformOrigin: "center center",
+ backfaceVisibility: "hidden",
+ pointerEvents: "none",
+ zIndex: 10,
+ userSelect: "none",
+ }}
+ >
+ {p.text}
+
+ ))}
+
+
+
+ CIM-Life 中央資管通
+
+
+ 歡迎加入資管所
+
+
+);
diff --git a/src/components/home/HomeBody.tsx b/src/components/home/HomeBody.tsx
new file mode 100644
index 0000000..653c99e
--- /dev/null
+++ b/src/components/home/HomeBody.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+import { IonContent } from "@ionic/react";
+import type { ParticleData } from "./types";
+import type { AnnouncementItem } from "../../services/announcement-api";
+import { HeroHeader } from "./HeroHeader";
+import { AnnouncementBar } from "./AnnouncementBar";
+import { HomeModuleList } from "./HomeModuleList";
+
+export const HomeBody = ({
+ stage,
+ isUnlocked,
+ particles,
+ announcements,
+ hovered,
+ onHover,
+ onLeave,
+ onLogoClick,
+ onOpenAnnouncements,
+}: Readonly<{
+ stage: number;
+ isUnlocked: boolean;
+ particles: readonly ParticleData[];
+ announcements: readonly AnnouncementItem[];
+ hovered: string | null;
+ onHover: (route: string) => void;
+ onLeave: () => void;
+ onLogoClick: () => void;
+ onOpenAnnouncements: () => void;
+}>) => (
+
+
+
+);
diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx
new file mode 100644
index 0000000..6a8d1e9
--- /dev/null
+++ b/src/components/home/HomeHeader.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import {
+ IonHeader,
+ IonToolbar,
+ IonTitle,
+ IonButtons,
+ IonButton,
+ IonIcon,
+} from "@ionic/react";
+import { schoolOutline } from "ionicons/icons";
+
+export const HomeHeader = ({ onOpenQuiz }: Readonly<{ onOpenQuiz: () => void }>) => (
+
+
+ CIM-Life
+
+
+
+ 教授
+
+
+
+
+);
diff --git a/src/components/home/HomeModuleList.tsx b/src/components/home/HomeModuleList.tsx
new file mode 100644
index 0000000..c96bbbc
--- /dev/null
+++ b/src/components/home/HomeModuleList.tsx
@@ -0,0 +1,194 @@
+import React from "react";
+import {
+ IonCard,
+ IonCardHeader,
+ IonCardTitle,
+ IonCardSubtitle,
+ IonIcon,
+ IonBadge,
+} from "@ionic/react";
+import {
+ map,
+ calendar,
+ restaurant,
+ calculator,
+ sparkles,
+} from "ionicons/icons";
+import type { ModuleCard } from "./types";
+import homeModulesJson from "../../data/home-modules.json";
+
+interface ModuleCardRaw {
+ readonly id: string;
+ readonly title: string;
+ readonly subtitle: string;
+ readonly icon: string;
+ readonly route: string;
+ readonly color: string;
+ readonly badge?: string;
+ readonly disabled?: boolean;
+ readonly hidden?: boolean;
+}
+
+const MODULE_ICONS: Record = {
+ calendar,
+ calculator,
+ map,
+ sparkles,
+ restaurant,
+};
+
+export const modules: readonly ModuleCard[] = (homeModulesJson as readonly ModuleCardRaw[]).map(
+ (m) => ({
+ ...m,
+ icon: MODULE_ICONS[m.icon] ?? calendar,
+ })
+);
+
+export const CardIcon = ({ color, icon }: Readonly<{ color: string; icon: string }>) => (
+
+
+
+);
+
+export const CardTitleRow = ({
+ title,
+ badge,
+ subtitle,
+ disabled,
+}: Readonly<{
+ title: string;
+ badge?: string;
+ subtitle: string;
+ disabled?: boolean;
+}>) => (
+
+
+
+ {title}
+
+ {badge && (
+
+ {badge}
+
+ )}
+
+
+ {subtitle}
+
+
+);
+
+export const ModuleCardItem = ({
+ mod,
+ isHovered,
+ onHover,
+ onLeave,
+}: Readonly<{
+ mod: ModuleCard;
+ isHovered: boolean;
+ onHover: () => void;
+ onLeave: () => void;
+}>) => {
+ if (mod.disabled) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const HomeModuleList = ({
+ hovered,
+ onHover,
+ onLeave,
+}: Readonly<{
+ hovered: string | null;
+ onHover: (route: string) => void;
+ onLeave: () => void;
+}>) => {
+ const visibleModules = modules.filter((m) => !m.hidden);
+
+ return (
+
+ {visibleModules.map((mod) => (
+ onHover(mod.route)}
+ onLeave={onLeave}
+ />
+ ))}
+
+ );
+};
diff --git a/src/components/home/index.ts b/src/components/home/index.ts
new file mode 100644
index 0000000..96dc8b8
--- /dev/null
+++ b/src/components/home/index.ts
@@ -0,0 +1,6 @@
+export * from "./types";
+export * from "./HeroHeader";
+export * from "./HomeModuleList";
+export * from "./AnnouncementBar";
+export * from "./HomeHeader";
+export * from "./HomeBody";
diff --git a/src/components/home/types.ts b/src/components/home/types.ts
new file mode 100644
index 0000000..944c97a
--- /dev/null
+++ b/src/components/home/types.ts
@@ -0,0 +1,25 @@
+export interface ParticleData {
+ readonly id: number;
+ readonly stage: number;
+ readonly text: string;
+ readonly side: "left" | "right";
+ readonly sideOffset: number;
+ readonly offsetY: number;
+ readonly flyY: number;
+ readonly driftX: number;
+ readonly scale: number;
+ readonly rotate: number;
+ readonly duration: number;
+}
+
+export interface ModuleCard {
+ readonly id: string;
+ readonly title: string;
+ readonly subtitle: string;
+ readonly icon: string;
+ readonly route: string;
+ readonly color: string;
+ readonly badge?: string;
+ readonly disabled?: boolean;
+ readonly hidden?: boolean;
+}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index 052fa29..b765406 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -1,29 +1,5 @@
import { useState, useRef, useEffect, useCallback } from "react";
-import {
- IonPage,
- IonHeader,
- IonToolbar,
- IonTitle,
- IonContent,
- IonCard,
- IonCardHeader,
- IonCardTitle,
- IonCardSubtitle,
- IonIcon,
- IonBadge,
- useIonAlert,
- IonButtons,
- IonButton,
-} from "@ionic/react";
-import {
- map,
- calendar,
- restaurant,
- calculator,
- sparkles,
- chevronForwardOutline,
- schoolOutline,
-} from "ionicons/icons";
+import { IonPage, useIonAlert } from "@ionic/react";
import {
AnnouncementItem,
BUILTIN_ANNOUNCEMENTS,
@@ -31,558 +7,14 @@ import {
} from "../services/announcement-api";
import AnnouncementModal from "../components/announcements/AnnouncementModal";
import { FacultyQuizModal } from "../components/faculty/FacultyQuizModal";
-import homeModulesJson from "../data/home-modules.json";
-
-interface ModuleCardRaw {
- readonly id: string;
- readonly title: string;
- readonly subtitle: string;
- readonly icon: string;
- readonly route: string;
- readonly color: string;
- readonly badge?: string;
- readonly disabled?: boolean;
- readonly hidden?: boolean;
-}
-
-interface ModuleCard extends Omit {
- readonly icon: string;
-}
-
-const MODULE_ICONS: Record = {
- calendar,
- calculator,
- map,
- sparkles,
- restaurant,
-};
-
-const modules: readonly ModuleCard[] = (homeModulesJson as readonly ModuleCardRaw[]).map(
- (m) => ({
- ...m,
- icon: MODULE_ICONS[m.icon] ?? calendar,
- })
-);
-
-const NcuimLogoIcon = () => (
-
-);
-
-const getStageIcon = (stage: number): string => {
- if (stage <= 0) return "";
- if (stage <= 4) return "⚡";
- if (stage <= 8) return "🔓";
- if (stage <= 12) return "🔥";
- if (stage <= 16) return "🚨";
- if (stage < 20) return "💥";
- return isCtfEnded() ? "🏆" : "🚩";
-};
-
-const getLogoStyle = (stage: number, isUnlocked: boolean): React.CSSProperties => {
- const base: React.CSSProperties = {
- width: 132,
- height: 132,
- marginBottom: 8,
- border: "none",
- overflow: "visible",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- background: "transparent",
- cursor: "pointer",
- userSelect: "none",
- padding: 0,
- transition: "transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.18s ease",
- willChange: "transform",
- backfaceVisibility: "hidden",
- };
-
- if (isUnlocked) {
- return {
- ...base,
- filter: "drop-shadow(0 0 12px #10b981)",
- animation: "unlockedPulse 3s ease-in-out infinite",
- };
- }
-
- if (stage <= 0) {
- return {
- ...base,
- filter: "drop-shadow(0 4px 12px rgba(27, 42, 74, 0.15))",
- transform: "none",
- };
- }
-
- const scale = 1.0 + (stage / 20) * 0.32;
- const rotate = getStageRotation(stage);
- const filter = getStageDropShadow(stage);
-
- return {
- ...base,
- transform: `scale(${scale.toFixed(2)}) rotate(${rotate}deg)`,
- filter,
- };
-};
-
-const getStageDropShadow = (stage: number): string => {
- if (stage >= 17) return "drop-shadow(0 0 24px #10b981) drop-shadow(0 0 12px #38bdf8)";
- if (stage >= 13) return "drop-shadow(0 0 20px rgba(239, 68, 68, 0.95))";
- if (stage >= 9) return "drop-shadow(0 0 16px rgba(249, 115, 22, 0.9))";
- if (stage >= 5) return "drop-shadow(0 0 12px rgba(168, 85, 247, 0.85))";
- return "drop-shadow(0 0 8px rgba(56, 189, 248, 0.8))";
-};
-
-const getStageRotation = (stage: number): number => {
- if (stage >= 20) return 360;
- return stage % 2 === 0 ? stage * 1.2 : -stage * 1.2;
-};
-
-interface ParticleData {
- id: number;
- stage: number;
- text: string;
- side: "left" | "right";
- sideOffset: number;
- offsetY: number;
- flyY: number;
- driftX: number;
- scale: number;
- rotate: number;
- duration: number;
-}
-
-const HeroHeader = ({
- stage,
- isUnlocked,
- particles,
- onLogoClick,
-}: Readonly<{
- stage: number;
- isUnlocked: boolean;
- particles: ParticleData[];
- onLogoClick: () => void;
-}>) => (
-
-
-
-
-
- {particles.map((p) => (
-
= 13 ? "rgba(15, 23, 42, 0.94)" : "rgba(15, 23, 42, 0.88)",
- border: `1.5px solid ${p.stage >= 13 ? "#ef4444" : "#38bdf8"}`,
- boxShadow: p.stage >= 13
- ? "0 0 16px rgba(239, 68, 68, 0.6)"
- : "0 0 14px rgba(56, 189, 248, 0.6)",
- animation: `cyberGlowParticle ${p.duration}s ease-out forwards`,
- willChange: "transform, opacity",
- transformOrigin: "center center",
- backfaceVisibility: "hidden",
- pointerEvents: "none",
- zIndex: 10,
- userSelect: "none",
- }}
- >
- {p.text}
-
- ))}
-
-
-
- CIM-Life 中央資管通
-
-
- 歡迎加入資管所
-
-
-);
-
-const CardIcon = ({ color, icon }: Readonly<{ color: string; icon: string }>) => (
-
-
-
-);
-
-const CardTitleRow = ({
- title,
- badge,
- subtitle,
- disabled,
-}: Readonly<{
- title: string;
- badge?: string;
- subtitle: string;
- disabled?: boolean;
-}>) => (
-
-
-
- {title}
-
- {badge && (
-
- {badge}
-
- )}
-
-
- {subtitle}
-
-
-);
-
-const ModuleCardItem = ({
- mod,
- isHovered,
- onHover,
- onLeave,
-}: Readonly<{
- mod: ModuleCard;
- isHovered: boolean;
- onHover: () => void;
- onLeave: () => void;
-}>) => {
- if (mod.disabled) {
- return (
-
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-
-const HomeHeader = ({ onOpenQuiz }: Readonly<{ onOpenQuiz: () => void }>) => (
-
-
- CIM-Life
-
-
-
- 教授
-
-
-
-
-);
-
-const HomeModuleList = ({
- hovered,
- onHover,
- onLeave,
-}: Readonly<{
- hovered: string | null;
- onHover: (route: string) => void;
- onLeave: () => void;
-}>) => {
- const visibleModules = modules.filter((m) => !m.hidden);
-
- return (
-
- {visibleModules.map((mod) => (
- onHover(mod.route)}
- onLeave={onLeave}
- />
- ))}
-
- );
-};
-
-const getPriorityBorderColor = (priority: string): string => {
- if (priority === "urgent") return "#f59e0b";
- if (priority === "high") return "#f97316";
- if (priority === "normal") return "#3b82f6";
- return "var(--ncu-ink)";
-};
-
-const AnnouncementBar = ({
- announcements,
- onOpen,
-}: Readonly<{
- announcements: readonly AnnouncementItem[];
- onOpen: () => void;
-}>) => {
- const latest = announcements[0];
- if (!latest) return null;
-
- const priorityBorderColor = getPriorityBorderColor(latest.priority);
-
- return (
-
- );
-};
-
-const HomeBody = ({
- stage,
- isUnlocked,
- particles,
- announcements,
- hovered,
- onHover,
- onLeave,
- onLogoClick,
- onOpenAnnouncements,
-}: Readonly<{
- stage: number;
- isUnlocked: boolean;
- particles: ParticleData[];
- announcements: readonly AnnouncementItem[];
- hovered: string | null;
- onHover: (route: string) => void;
- onLeave: () => void;
- onLogoClick: () => void;
- onOpenAnnouncements: () => void;
-}>) => (
-
-
-
-);
-
-const CTF_CONFIG = {
- activeUrl: "https://im2026ctf.duckdns.org/",
- scoreboardUrl: "https://im2026ctf.duckdns.org/scoreboard",
- endTime: "2026-09-10T10:00:00+08:00",
-};
-
-const isCtfEnded = (): boolean => {
- try {
- return Date.now() > new Date(CTF_CONFIG.endTime).getTime();
- } catch {
- return false;
- }
-};
+import {
+ HomeHeader,
+ HomeBody,
+ ParticleData,
+ CTF_CONFIG,
+ isCtfEnded,
+ getStageIcon,
+} from "../components/home";
const HomePage = () => {
const [hovered, setHovered] = useState(null);
@@ -603,14 +35,14 @@ const HomePage = () => {
});
}, []);
-const getCryptoRandom = (): number => {
- if (typeof window !== "undefined" && window.crypto?.getRandomValues) {
- const arr = new Uint32Array(1);
- window.crypto.getRandomValues(arr);
- return arr[0] / 0x100000000;
- }
- return 0.5;
-};
+ const getCryptoRandom = (): number => {
+ if (typeof window !== "undefined" && window.crypto?.getRandomValues) {
+ const arr = new Uint32Array(1);
+ window.crypto.getRandomValues(arr);
+ return arr[0] / 0x100000000;
+ }
+ return 0.5;
+ };
const addParticle = useCallback((stage: number, text: string) => {
const side = getCryptoRandom() < 0.5 ? "left" : "right";
From 19b978a8d34b0943a4acdcbdb6be91b0b02b06d6 Mon Sep 17 00:00:00 2001
From: Youchen Jiang <1014308g@gmail.com>
Date: Thu, 8 Oct 2026 10:21:05 +0800
Subject: [PATCH 2/5] refactor(app): isolate ctf challenge logic into service
layer
1. Move CTF_CONFIG and isCtfEnded from HeroHeader to src/services/ctf-challenge.ts.
2. Decouple challenge orchestration and date checks from UI components.
3. Resolve Sourcery review recommendation on PR #53.
---
src/components/home/HeroHeader.tsx | 15 +--------------
src/pages/HomePage.tsx | 3 +--
src/services/ctf-challenge.ts | 13 +++++++++++++
3 files changed, 15 insertions(+), 16 deletions(-)
create mode 100644 src/services/ctf-challenge.ts
diff --git a/src/components/home/HeroHeader.tsx b/src/components/home/HeroHeader.tsx
index 076898a..c436ac0 100644
--- a/src/components/home/HeroHeader.tsx
+++ b/src/components/home/HeroHeader.tsx
@@ -1,19 +1,6 @@
import React from "react";
import type { ParticleData } from "./types";
-
-export const CTF_CONFIG = {
- activeUrl: "https://im2026ctf.duckdns.org/",
- scoreboardUrl: "https://im2026ctf.duckdns.org/scoreboard",
- endTime: "2026-09-10T10:00:00+08:00",
-} as const;
-
-export const isCtfEnded = (): boolean => {
- try {
- return Date.now() > new Date(CTF_CONFIG.endTime).getTime();
- } catch {
- return false;
- }
-};
+import { isCtfEnded } from "../../services/ctf-challenge";
export const getStageIcon = (stage: number): string => {
if (stage <= 0) return "";
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index b765406..78f98b2 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -5,14 +5,13 @@ import {
BUILTIN_ANNOUNCEMENTS,
fetchAnnouncements,
} from "../services/announcement-api";
+import { CTF_CONFIG, isCtfEnded } from "../services/ctf-challenge";
import AnnouncementModal from "../components/announcements/AnnouncementModal";
import { FacultyQuizModal } from "../components/faculty/FacultyQuizModal";
import {
HomeHeader,
HomeBody,
ParticleData,
- CTF_CONFIG,
- isCtfEnded,
getStageIcon,
} from "../components/home";
diff --git a/src/services/ctf-challenge.ts b/src/services/ctf-challenge.ts
new file mode 100644
index 0000000..01e9bde
--- /dev/null
+++ b/src/services/ctf-challenge.ts
@@ -0,0 +1,13 @@
+export const CTF_CONFIG = {
+ activeUrl: "https://im2026ctf.duckdns.org/",
+ scoreboardUrl: "https://im2026ctf.duckdns.org/scoreboard",
+ endTime: "2026-09-10T10:00:00+08:00",
+} as const;
+
+export const isCtfEnded = (): boolean => {
+ try {
+ return Date.now() > new Date(CTF_CONFIG.endTime).getTime();
+ } catch {
+ return false;
+ }
+};
From deda18137701b052132b92c0e92ececc6fe8e2a1 Mon Sep 17 00:00:00 2001
From: Youchen Jiang <1014308g@gmail.com>
Date: Thu, 8 Oct 2026 10:46:58 +0800
Subject: [PATCH 3/5] fix(ci): reject broken or trailing backslashes in pr body
checklist
1. Check for unescaped leading or trailing backslashes in checklist items.
2. Add regression tests covering escaped newline artifacts in verification section.
3. Prevent malformed Markdown from passing commit policy gates.
---
scripts/commit-policy.mjs | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/scripts/commit-policy.mjs b/scripts/commit-policy.mjs
index f7b62b3..908a57a 100644
--- a/scripts/commit-policy.mjs
+++ b/scripts/commit-policy.mjs
@@ -327,9 +327,18 @@ function validateSection(heading, content, errors) {
}
if (lower.includes("verification")) {
- const checklistItems = content
+ const rawChecklistLines = content
.split("\n")
.map((line) => line.trim())
+ .filter((line) => /^[-*+]\s+\[[ xX]\]/.test(line));
+
+ for (const rawLine of rawChecklistLines) {
+ if (/(?:^|[^\\])\\[\s\S]*$/.test(rawLine) && (/\\$/.test(rawLine) || /^[-*+]\s+\[[ xX]\]\s*\\/.test(rawLine))) {
+ errors.push(`Checklist item contains invalid or escaped backslashes: "${rawLine}".`);
+ }
+ }
+
+ const checklistItems = rawChecklistLines
.filter((line) => /^[-*+]\s+\[[ xX]\]\s+\S+/.test(line))
.map((line) => line.replace(/^[-*+]\s+\[[ xX]\]\s+/, "").trim());
@@ -431,6 +440,8 @@ function runSelfTest() {
check("vague summary in pr body", validatePrBody("## Summary\nTODO\n## Key Changes\n1. Done\n## Verification\n- [x] OK"), true);
check("vague key changes in pr body", validatePrBody("## Summary\nValid summary\n## Key Changes\n1. TBD\n## Verification\n- [x] OK"), true);
check("vague verification in pr body", validatePrBody("## Summary\nValid summary\n## Key Changes\n1. Done\n## Verification\n- [x] N/A"), true);
+ check("trailing backslash in verification checklist", validatePrBody("## Summary\nValid summary\n## Key Changes\n1. Done\n## Verification\n- [x] px tsc passes\\\n"), true);
+ check("leading backslash in verification checklist item", validatePrBody("## Summary\nValid summary\n## Key Changes\n1. Done\n## Verification\n- [x] \\npx tsc passes\n"), true);
check("out of order headings in pr body", validatePrBody("## Key Changes\n1. Done\n## Summary\nValid summary\n## Verification\n- [x] OK"), true);
check("fenced code block with fake headings does not satisfy required headings", validatePrBody("```markdown\n## Summary\nFake summary\n## Key Changes\n1. Fake\n## Verification\n- [x] Fake\n```"), true);
check("trailing hashes on heading line are consumed properly", validatePrBody("## Summary ###\n\n## Key Changes\n1. Done\n## Verification\n- [x] OK"), true);
From 538ef53f332632a07e154d930c21a89e10bfccf7 Mon Sep 17 00:00:00 2001
From: Youchen Jiang <1014308g@gmail.com>
Date: Thu, 8 Oct 2026 10:50:52 +0800
Subject: [PATCH 4/5] feat(ci): align pr body validation with tool-scripts
parity
1. Intercept corrupted escaped inline code artifacts in PR descriptions.
2. Validate balanced backtick count across PR body.
3. Add self-test cases verifying backtick and artifact detection.
---
scripts/commit-policy.mjs | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/scripts/commit-policy.mjs b/scripts/commit-policy.mjs
index 908a57a..871cb6a 100644
--- a/scripts/commit-policy.mjs
+++ b/scripts/commit-policy.mjs
@@ -357,6 +357,19 @@ export function validatePrBody(body) {
return errors;
}
+ // Parity with tool-scripts: check for corrupted escaped inline code artifacts (e.g. \npm test\ instead of `npm test`)
+ const escapedArtifactPattern = /\\([a-zA-Z0-9_./#:@<>()'" -]+)\\/;
+ if (escapedArtifactPattern.test(rawText)) {
+ const matched = rawText.match(escapedArtifactPattern);
+ errors.push(`PR body contains corrupted escaped inline code artifacts ("${matched[0]}" instead of \`${matched[1]}\`). Use backticks for code and paths.`);
+ }
+
+ // Parity with tool-scripts: check for unmatched backticks across the entire PR body
+ const backtickCount = (rawText.match(/`/g) || []).length;
+ if (backtickCount % 2 !== 0) {
+ errors.push(`PR body has an unmatched backtick (total: ${backtickCount}). Ensure all code spans are closed.`);
+ }
+
const text = stripFencedCodeBlocks(rawText);
const headings = POLICY.prBody.requiredHeadings;
const headingMatches = extractHeadingMatches(text, headings, errors);
@@ -442,6 +455,8 @@ function runSelfTest() {
check("vague verification in pr body", validatePrBody("## Summary\nValid summary\n## Key Changes\n1. Done\n## Verification\n- [x] N/A"), true);
check("trailing backslash in verification checklist", validatePrBody("## Summary\nValid summary\n## Key Changes\n1. Done\n## Verification\n- [x] px tsc passes\\\n"), true);
check("leading backslash in verification checklist item", validatePrBody("## Summary\nValid summary\n## Key Changes\n1. Done\n## Verification\n- [x] \\npx tsc passes\n"), true);
+ check("unmatched backticks in pr body", validatePrBody("## Summary\nValid summary `code\n## Key Changes\n1. Done\n## Verification\n- [x] OK\n"), true);
+ check("corrupted escaped inline code artifact", validatePrBody("## Summary\nValid summary \\npm test\\\n## Key Changes\n1. Done\n## Verification\n- [x] OK\n"), true);
check("out of order headings in pr body", validatePrBody("## Key Changes\n1. Done\n## Summary\nValid summary\n## Verification\n- [x] OK"), true);
check("fenced code block with fake headings does not satisfy required headings", validatePrBody("```markdown\n## Summary\nFake summary\n## Key Changes\n1. Fake\n## Verification\n- [x] Fake\n```"), true);
check("trailing hashes on heading line are consumed properly", validatePrBody("## Summary ###\n\n## Key Changes\n1. Done\n## Verification\n- [x] OK"), true);
From b56105b5443d03527eefc430df1ad63d9404fcd2 Mon Sep 17 00:00:00 2001
From: Youchen Jiang <1014308g@gmail.com>
Date: Thu, 8 Oct 2026 10:58:02 +0800
Subject: [PATCH 5/5] refactor(ci): use regexp exec instead of test and match
combo
1. Replace test and match calls on escapedArtifactPattern with single exec call.
2. Resolve SonarCloud maintainability rule S6594 on commit-policy.mjs.
3. Keep test policy behavior and parity checks identical.
---
scripts/commit-policy.mjs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/scripts/commit-policy.mjs b/scripts/commit-policy.mjs
index 871cb6a..41fa848 100644
--- a/scripts/commit-policy.mjs
+++ b/scripts/commit-policy.mjs
@@ -359,9 +359,9 @@ export function validatePrBody(body) {
// Parity with tool-scripts: check for corrupted escaped inline code artifacts (e.g. \npm test\ instead of `npm test`)
const escapedArtifactPattern = /\\([a-zA-Z0-9_./#:@<>()'" -]+)\\/;
- if (escapedArtifactPattern.test(rawText)) {
- const matched = rawText.match(escapedArtifactPattern);
- errors.push(`PR body contains corrupted escaped inline code artifacts ("${matched[0]}" instead of \`${matched[1]}\`). Use backticks for code and paths.`);
+ const escapedArtifactMatch = escapedArtifactPattern.exec(rawText);
+ if (escapedArtifactMatch) {
+ errors.push(`PR body contains corrupted escaped inline code artifacts ("${escapedArtifactMatch[0]}" instead of \`${escapedArtifactMatch[1]}\`). Use backticks for code and paths.`);
}
// Parity with tool-scripts: check for unmatched backticks across the entire PR body