diff --git a/scripts/commit-policy.mjs b/scripts/commit-policy.mjs
index f7b62b3..41fa848 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());
@@ -348,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_./#:@<>()'" -]+)\\/;
+ 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
+ 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);
@@ -431,6 +453,10 @@ 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("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);
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..c436ac0
--- /dev/null
+++ b/src/components/home/HeroHeader.tsx
@@ -0,0 +1,199 @@
+import React from "react";
+import type { ParticleData } from "./types";
+import { isCtfEnded } from "../../services/ctf-challenge";
+
+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..78f98b2 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -1,588 +1,19 @@
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,
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 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,
+ getStageIcon,
+} from "../components/home";
const HomePage = () => {
const [hovered, setHovered] = useState(null);
@@ -603,14 +34,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";
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;
+ }
+};