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
28 changes: 27 additions & 1 deletion scripts/commit-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
92 changes: 92 additions & 0 deletions src/components/home/AnnouncementBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={onOpen}
aria-label="查看最新公告"
style={{
margin: "0 0 16px",
padding: "10px 14px",
width: "100%",
textAlign: "inherit",
font: "inherit",
background: "var(--ncu-surface)",
border: `2px solid ${priorityBorderColor}`,
borderRadius: "var(--ncu-radius-md)",
boxShadow: "var(--ncu-shadow-sm)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
cursor: "pointer",
transition: "all 0.2s ease",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0, flex: 1 }}>
<span
style={{
color: "var(--ncu-muted)",
fontSize: 11,
fontWeight: 700,
display: "inline-flex",
alignItems: "center",
gap: 3,
flexShrink: 0,
}}
>
<span>公告</span>
</span>
<span
style={{
fontSize: 13.5,
fontWeight: 700,
color: "var(--ncu-ink)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{latest.title}
</span>
</div>

<div
style={{
display: "flex",
alignItems: "center",
gap: 2,
color: "var(--ncu-muted)",
fontSize: 12,
fontWeight: 700,
flexShrink: 0,
}}
>
<span>{announcements.length > 1 ? `共 ${announcements.length} 則` : "詳情"}</span>
<IonIcon icon={chevronForwardOutline} style={{ fontSize: 14 }} />
</div>
</button>
);
};
199 changes: 199 additions & 0 deletions src/components/home/HeroHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="100%" height="100%" aria-hidden="true">
<image href="/ncuim-icons/hero-logo.svg" x="0" y="0" width="128" height="128" preserveAspectRatio="xMidYMid meet" />
</svg>
);

export const HeroHeader = ({
stage,
isUnlocked,
particles,
onLogoClick,
}: Readonly<{
stage: number;
isUnlocked: boolean;
particles: readonly ParticleData[];
onLogoClick: () => void;
}>) => (
<div
style={{
textAlign: "center",
padding: "var(--ncu-space-3) 0 var(--ncu-space-2)",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<style>{`
@keyframes unlockedPulse {
0%, 100% {
transform: scale(1.04);
filter: drop-shadow(0 0 16px rgba(16, 185, 129, 0.85)) drop-shadow(0 0 8px rgba(56, 189, 248, 0.6));
}
50% {
transform: scale(1.08);
filter: drop-shadow(0 0 28px rgba(16, 185, 129, 1)) drop-shadow(0 0 14px rgba(56, 189, 248, 0.8));
}
}
@keyframes cyberGlowParticle {
0% {
opacity: 0;
transform: translate3d(0, 8px, 0) scale(0.85);
}
15% {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
60% {
opacity: 0.85;
transform: translate3d(calc(var(--drift-x, 0px) * 0.5), calc(var(--fly-y, -50px) * 0.5), 0) scale(0.8);
}
100% {
opacity: 0;
transform: translate3d(var(--drift-x, 0px), var(--fly-y, -50px), 0) scale(0.25);
}
}
`}</style>
<div style={{ position: "relative", display: "inline-flex", marginBottom: 14 }}>
<button
type="button"
onClick={onLogoClick}
aria-label="NCUIM Logo"
style={getLogoStyle(stage, isUnlocked)}
title="NCUIM"
>
<NcuimLogoIcon />
</button>

{particles.map((p) => (
<div
key={p.id}
style={{
position: "absolute",
top: `calc(50% + ${p.offsetY}px)`,
left: p.side === "right" ? `calc(100% + ${p.sideOffset}px)` : "auto",
right: p.side === "left" ? `calc(100% + ${p.sideOffset}px)` : "auto",
["--fly-y" as string]: `${p.flyY}px`,
["--drift-x" as string]: `${p.driftX}px`,
fontSize: 20,
lineHeight: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: "50%",
background: p.stage >= 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}
</div>
))}
</div>

<h1
style={{
fontSize: "var(--ncu-font-size-3xl)",
fontWeight: "var(--ncu-font-weight-bold)",
margin: 0,
lineHeight: 1.2,
color: "var(--ncu-ink)",
}}
>
CIM-Life 中央資管通
</h1>
<p
style={{
fontSize: "var(--ncu-font-size-base)",
color: "var(--ncu-muted)",
margin: "var(--ncu-space-1) 0 0",
}}
>
歡迎加入資管所
</p>
</div>
);
42 changes: 42 additions & 0 deletions src/components/home/HomeBody.tsx
Original file line number Diff line number Diff line change
@@ -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;
}>) => (
<IonContent className="ion-padding">
<div style={{ maxWidth: 680, margin: "0 auto" }}>
<HeroHeader
stage={stage}
isUnlocked={isUnlocked}
particles={particles}
onLogoClick={onLogoClick}
/>
<AnnouncementBar announcements={announcements} onOpen={onOpenAnnouncements} />
<HomeModuleList hovered={hovered} onHover={onHover} onLeave={onLeave} />
</div>
</IonContent>
);
Loading
Loading