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; + } +};