NYCU EM3 - E3 UI Plus

強化 NYCU E3 全站介面與操作體驗。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         NYCU EM3 - E3 UI Plus
// @name:en      NYCU EM3 - E3 Interface Optimization
// @name:zh-CN   NYCU EM3 - E3 介面最佳化
// @name:zh-TW   NYCU EM3 - E3 介面最佳化
// @name:zh      NYCU EM3 - E3 介面最佳化
// @namespace    http://tampermonkey.net/
// @version      1.2.6
// @description  強化 NYCU E3 全站介面與操作體驗。
// @description:en  Improve NYCU E3 full-site UI/UX.
// @description:zh-CN  强化 NYCU E3 全站介面与操作体验。
// @description:zh-TW  強化 NYCU E3 全站介面與操作體驗。
// @description:zh  強化 NYCU E3 全站介面與操作體驗。
// @author       Elvis Mao
// @match        https://e3p.nycu.edu.tw/*
// @icon         https://emtech.cc/static/icons/apple-touch-icon.png
// @license      Apache-2.0
// @homepageURL  https://github.com/Edit-Mr/SSS/tree/main
// @supportURL   https://github.com/Edit-Mr/SSS/issues
// @run-at       document-start
// @grant        GM_addStyle
// ==/UserScript==

(() => {
	"use strict";

	// 嘗試載入 CSS。如果失敗的話代表可能是使用 dev.js 在跑,交給他就好。
	try {
		GM_addStyle("@import url('https://g.elvismao.com/nycu-em3/index.css');@import url('https://g.elvismao.com/nycu-em3/home.css');");
	} catch (err) {
		console.warn("[TM] Failed to load external CSS, maybe you're in dev mode.");
	}

	const path = location.pathname.replace(/\/+$/, "") || "/";
	const isDashboard = location.origin === "https://e3p.nycu.edu.tw" && (path === "/my" || path === "/my/index.php");
	const isEnglish = new URLSearchParams(location.search).get("lang") === "en" || (document.documentElement.lang || "").startsWith("en");

	function boot() {
		try {
			console.log(`[TM] NYCU E3 UI Plus loaded. Dashboard: ${isDashboard}, English: ${isEnglish}`);
			initTheme();
			patchNavbar();
			if (isDashboard) initDashboard();
		} catch (err) {
			console.error("[TM] Error in main execution:", err);
		}
	}

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	// --- tool functions ---
	function initTheme() {
		const savedTheme = localStorage.getItem("em-theme");

		if (savedTheme === "dark") {
			document.body.classList.add("darkmode");
		} else if (savedTheme === "light") {
			document.body.classList.remove("darkmode");
		} else {
			const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
			if (prefersDark) {
				document.body.classList.add("darkmode");
			}
		}

		window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
			if (!localStorage.getItem("em-theme")) {
				if (e.matches) {
					document.body.classList.add("darkmode");
				} else {
					document.body.classList.remove("darkmode");
				}
			}
		});
	}

	function toggleTheme() {
		const isDark = document.body.classList.contains("darkmode");

		if (isDark) {
			document.body.classList.remove("darkmode");
			localStorage.setItem("em-theme", "light");
		} else {
			document.body.classList.add("darkmode");
			localStorage.setItem("em-theme", "dark");
		}

		updateThemeToggleIcon(!isDark);
	}

	function updateThemeToggleIcon(isDark) {
		const toggleBtn = document.querySelector("#theme-toggle");
		if (toggleBtn) {
			toggleBtn.innerHTML = isDark ? "☀️" : "🌙";
			toggleBtn.setAttribute("aria-label", isDark ? "切換到亮色模式" : "切換到暗色模式");
		}
	}

	function patchNavbar() {
		const navbar = document.querySelector("nav.navbar.fixed-top");
		if (!navbar) return;

		// 移除空的 primary-navigation 和 page_heading_menu
		navbar.querySelector(".primary-navigation")?.remove();
		navbar.querySelector("ul.navbar-nav.d-none.d-md-flex")?.remove();
		navbar.querySelector(".navbar-toggler")?.remove();

		document.querySelector("nav .navbar-brand").innerHTML = `
			<img src="https://g.elvismao.com/nycu-em3/e3.svg" alt="E3 Logo" style="height:32px; margin-right:8px;">
			<span style="font-weight:700;">NYCU EM3</span>
		`;

		// Add theme toggle button to navbar
		const userNavigation = navbar.querySelector("#usernavigation");
		if (userNavigation) {
			const themeToggleContainer = document.createElement("div");
			themeToggleContainer.className = "popover-region collapsed";

			const isDark = document.body.classList.contains("darkmode");
			themeToggleContainer.innerHTML = `
				<button 
					id="theme-toggle" 
					class="nav-link popover-region-toggle position-relative icon-no-margin" 
					style="background:none; border:none; color:var(--em-text);"
					aria-label="${isDark ? "切換到亮色模式" : "切換到暗色模式"}"
				>
					${isDark ? "☀️" : "🌙"}
				</button>
			`;

			userNavigation.insertBefore(themeToggleContainer, userNavigation.firstChild);
			const toggleBtn = themeToggleContainer.querySelector("#theme-toggle");
			toggleBtn.addEventListener("click", toggleTheme);
		}

		// set favicon
		const favicon = document.querySelector('link[rel="icon"]') || document.createElement("link");
		favicon.rel = "icon";
		favicon.href = "https://g.elvismao.com/nycu-em3/e3.svg";
		document.head.appendChild(favicon);

		// 升級 Gravatar 解析度(頭像 img)
		const avatarImg = navbar.querySelector("#user-menu-toggle img.userpicture");
		if (avatarImg?.src) {
			avatarImg.src = avatarImg.src.replace("s=35", "s=200");
			avatarImg.removeAttribute("width");
			avatarImg.removeAttribute("height");
		}
	}

	function initDashboard() {
		const data = extractDashboardData();
		const app = buildDashboardApp(data);

		document.getElementById("page")?.remove();
		document.getElementById("page-wrapper").appendChild(app);
	}

	function extractDashboardData() {
		const siteLogo = document.querySelector(".navbar-brand img")?.src || document.querySelector(".drawerheader a img")?.src || "";

		const avatarRaw = document.querySelector("#user-menu-toggle img")?.src || document.querySelector(".myprofileitem.picture img")?.src || "";
		// Gravatar 預設 s=35 畫質很差,換成 200
		const avatar = avatarRaw.replace("s=35", "s=200");

		const notificationCount = cleanText(document.querySelector("#nav-notification-popover-container .count-container")?.textContent) || "";

		const lang = cleanText([...document.querySelectorAll("#usernavigation a.nav-link")].map(el => el.textContent).find(t => /\bTW\b/i.test(t))) || "TW";

		const profileName = cleanText(document.querySelector("#inst82730 .myprofileitem.fullname")?.textContent) || "已登入使用者";

		const country = cleanText(document.querySelector("#inst82730 .myprofileitem.country")?.textContent).replace(/^國家:\s*/, "");

		const englishName = cleanText(document.querySelectorAll("#inst82730 .myprofileitem.city")[0]?.textContent).replace(/^英文姓名:\s*/, "");

		const email = cleanText(document.querySelectorAll("#inst82730 .myprofileitem.city")[1]?.textContent).replace(/^電子郵件信箱:\s*/, "");

		const courses = parseCourses();
		const announcements = parseAnnouncements();
		const events = parseEvents();

		// Collect unique terms in DOM order (newest first); courses without a term go under "其他"
		const allTerms = [...new Set(courses.map(c => c.term).filter(Boolean))];
		if (courses.some(c => !c.term)) allTerms.push("其他");
		const currentTerm = allTerms[0] || "";

		return {
			siteLogo,
			avatar,
			notificationCount,
			lang,
			isEnglish,
			profileName,
			country,
			englishName,
			email,
			currentTerm,
			allTerms,
			courses,
			announcements,
			events
		};
	}

	function parseCourses() {
		// 優先抓右側主課程清單;若只有一個學期,再補入左側 sidebar(可能包含舊學期)
		const rightNodes = [...document.querySelectorAll("#layer2_right_current_course_stu a.course-link")];
		const leftNodes = [...document.querySelectorAll("#layer2_right_current_course_left a.course-link")];

		// 右側已有的 href set,用來去重
		const rightHrefs = new Set(rightNodes.map(a => normalizeHref(a.getAttribute("href"))).filter(Boolean));

		// 把左側有、但右側沒有的補進來(舊學期課程)
		const extraNodes = leftNodes.filter(a => !rightHrefs.has(normalizeHref(a.getAttribute("href"))));

		const nodes = [...rightNodes, ...extraNodes];
		const seen = new Set();
		const items = [];

		for (const a of nodes) {
			const href = normalizeHref(a.getAttribute("href"));
			const raw = cleanText(a.textContent);
			if (!href || !raw || seen.has(href)) continue;
			seen.add(href);

			const termMatch = raw.match(/【([^】]+)】/);
			const term = termMatch ? termMatch[1] : "";

			// 去掉【學期】前綴與課號後,剩下「中文名稱 英文名稱」
			const body = raw
				.replace(/^\s*【[^】]+】\s*/, "")
				.replace(/^\d+\s*/, "")
				.trim();

			// 英文部分:從第一個「空白+大寫英文字母」開始到結尾
			const enMatch = body.match(/\s+([A-Z].*)$/);
			const titleEn = enMatch ? enMatch[1].trim() : "";
			const titleZh = enMatch ? body.slice(0, enMatch.index).trim() : body;

			const title = titleZh || raw;

			items.push({ title, titleZh: title, titleEn, href, term });
		}

		return items;
	}

	function parseAnnouncements() {
		const posts = [...document.querySelectorAll("#inst20 .post")];
		const announcements = posts.map(post => {
			const dateLine = cleanText(post.querySelector(".date")?.textContent);
			const courseRaw = cleanText(post.querySelector(".date b")?.textContent);
			const title = cleanText(post.querySelector(".name")?.textContent);
			const info = cleanText(post.querySelector(".info")?.textContent);
			const link = normalizeHref(post.querySelector(".info a")?.getAttribute("href"));

			const rawTime = dateLine.replace(courseRaw, "").trim();
			const formattedTime = formatRelativeDate(rawTime);

			// 格式:1142.515501.離散數學 Discrete Mathematics → 離散數學
			// 先去掉「學期代碼.課號.」前綴,再去掉英文名稱
			const course =
				courseRaw
					.replace(/^\d+\.\d+\./, "") // 去掉 "1142.515501."
					.replace(/\s+[A-Za-z][\s\S]*$/, "") // 去掉英文名稱
					.trim() || courseRaw;

			// 為了排序,保留原始時間用於解析
			const timeMatch = rawTime.match(/(\d{1,2})月\s*(\d{1,2})日,(\d{1,2}):(\d{2})/);
			let sortDate = new Date();
			if (timeMatch) {
				const [, month, day, hour, minute] = timeMatch;
				const currentYear = new Date().getFullYear();
				sortDate = new Date(currentYear, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));

				// 如果解析出來的日期比現在晚,則可能是去年
				if (sortDate > new Date()) {
					sortDate = new Date(currentYear - 1, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));
				}
			}

			return {
				course,
				time: formattedTime,
				title,
				info,
				href: link || "#",
				sortDate
			};
		});

		// 按日期排序,最新的在前面
		return announcements.sort((a, b) => b.sortDate - a.sortDate);
	}

	function parseEvents() {
		const items = [...document.querySelectorAll('#inst11984 [data-region="event-item"]')];
		return items.slice(0, 4).map(item => {
			const title = cleanText(item.querySelector("h6 a")?.textContent);
			const href = normalizeHref(item.querySelector("h6 a")?.getAttribute("href"));
			const time = cleanText(item.querySelector(".date")?.textContent);
			return { title, href, time };
		});
	}

	function buildDashboardApp(data) {
		const app = document.createElement("div");
		app.className = "em-app";

		app.innerHTML = `
      <main class="em-main">
        <div class="em-col">
          <section class="em-section">
            <div class="em-section-head">
              <h2 class="em-section-title">課程列表</h2>
              ${
								data.allTerms.length > 1
									? `<div class="em-term-dropdown" id="em-term-dropdown">
                      <button class="em-pill em-pill-btn" id="em-term-btn" aria-haspopup="true" aria-expanded="false">
                        ${escapeHTML(data.currentTerm)} <span class="em-pill-caret">▾</span>
                      </button>
                      <ul class="em-term-menu" id="em-term-menu" role="listbox">
                        ${data.allTerms
													.map(t => {
														const val = t === "其他" ? "" : t;
														return `<li class="em-term-option${t === data.currentTerm ? " active" : ""}" role="option" data-term="${escapeAttr(val)}">${escapeHTML(t)}</li>`;
													})
													.join("")}
                      </ul>
                    </div>`
									: data.currentTerm
										? `<div class="em-pill">${escapeHTML(data.currentTerm)}</div>`
										: ""
							}
            </div>
            ${
							data.courses.length
								? `<div class="em-course-grid" id="em-course-grid">
                    ${data.courses
											.map(
												course => `
                          <a class="em-course-chip" href="${escapeAttr(course.href)}" data-term="${escapeAttr(course.term)}"${course.term !== data.currentTerm ? ' style="display:none"' : ""}>
                            ${escapeHTML(data.isEnglish && course.titleEn ? course.titleEn : course.titleZh)}
                          </a>
                        `
											)
											.join("")}
                  </div>`
								: `<div class="em-empty">沒有抓到課程資料</div>`
						}
          </section>

          <section class="em-section">
            <div class="em-section-head">
              <h2 class="em-section-title">已登入使用者</h2>
            </div>
            <div class="em-card em-profile-card">
              <div class="em-profile-pic">
                ${data.avatar ? `<img src="${escapeAttr(data.avatar)}" alt="profile">` : ""}
              </div>
              <div>
                <div class="em-profile-name">${escapeHTML(data.profileName)}</div>
                ${data.country ? `<div class="em-profile-line"><b>國家:</b> ${escapeHTML(data.country)}</div>` : ""}
                ${data.englishName ? `<div class="em-profile-line"><b>英文姓名:</b> ${escapeHTML(data.englishName)}</div>` : ""}
                ${data.email ? `<div class="em-profile-line"><b>電子郵件信箱:</b> ${escapeHTML(data.email)}</div>` : ""}
              </div>
            </div>
          </section>
        </div>

        <div class="em-col">
          <section class="em-section">
            <div class="em-section-head">
              <h2 class="em-section-title">課程公告</h2>
            </div>
            <div class="em-card em-announcements">
              ${
								data.announcements.length
									? data.announcements
											.map(
												item => `
                          <a class="em-announcement" href="${escapeAttr(item.href)}">
                            <div class="em-announcement-meta">
                              <span class="em-announcement-course">${escapeHTML(item.course)}</span>
                              ${item.time ? ` / ${escapeHTML(item.time)}` : ""}
                            </div>
                            <div class="em-announcement-title">${escapeHTML(item.title)}</div>
                            <div class="em-announcement-desc">${escapeHTML(item.info)}</div>
                          </a>
                        `
											)
											.join("")
									: `<div class="em-empty">沒有抓到公告資料</div>`
							}
            </div>
          </section>

          <section class="em-section">
            <div class="em-section-head">
              <h2 class="em-section-title">未來事件</h2>
            </div>
            <div class="em-card em-events">
              ${
								data.events.length
									? data.events
											.map(
												event => `
                          <a class="em-event" href="${escapeAttr(event.href)}">
                            <div class="em-event-icon">🎓</div>
                            <div>
                              <div class="em-event-title">${escapeHTML(event.title)}</div>
                              <div class="em-event-time">${escapeHTML(event.time)}</div>
                            </div>
                          </a>
                        `
											)
											.join("")
									: `<div class="em-empty">沒有抓到未來事件</div>`
							}
            </div>
          </section>
        </div>
      </main>
    `;

		// Term dropdown interaction
		const termBtn = app.querySelector("#em-term-btn");
		const termMenu = app.querySelector("#em-term-menu");
		const courseGrid = app.querySelector("#em-course-grid");
		if (termBtn && termMenu && courseGrid) {
			let activeTerm = data.currentTerm;

			const applyTerm = term => {
				activeTerm = term;

				// Update pill label
				const label = term === "" ? "其他" : term;
				termBtn.childNodes[0].textContent = label + " ";

				// Show/hide chips
				courseGrid.querySelectorAll(".em-course-chip").forEach(chip => {
					const t = chip.dataset.term;
					chip.style.display = t === term ? "" : "none";
				});

				// Update active state in menu
				termMenu.querySelectorAll(".em-term-option").forEach(opt => {
					opt.classList.toggle("active", opt.dataset.term === term);
				});
			};

			termBtn.addEventListener("click", e => {
				e.stopPropagation();
				const open = termMenu.classList.toggle("open");
				termBtn.setAttribute("aria-expanded", String(open));
			});

			termMenu.addEventListener("click", e => {
				const option = e.target.closest(".em-term-option");
				if (!option) return;
				applyTerm(option.dataset.term);
				termMenu.classList.remove("open");
				termBtn.setAttribute("aria-expanded", "false");
			});

			// Close on outside click
			document.addEventListener("click", () => {
				termMenu.classList.remove("open");
				termBtn.setAttribute("aria-expanded", "false");
			});
		}

		return app;
	}

	function normalizeHref(href) {
		if (!href) return "";
		try {
			return new URL(href, location.origin).href;
		} catch {
			return href;
		}
	}

	function cleanText(value) {
		return (value || "")
			.replace(/\u00a0/g, " ")
			.replace(/\s+/g, " ")
			.trim();
	}

	function escapeHTML(str) {
		return String(str).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
	}

	function formatRelativeDate(dateStr) {
		// 解析原始日期字串 "03月 16日,15:49"
		const match = dateStr.match(/(\d{1,2})月\s*(\d{1,2})日,(\d{1,2}):(\d{2})/);
		if (!match) return dateStr;

		const [, month, day] = match;
		const now = new Date();
		const currentYear = now.getFullYear();

		// 假設是當年的日期,如果解析出來的日期比現在晚,則可能是去年
		let targetDate = new Date(currentYear, parseInt(month) - 1, parseInt(day), 0, 0);
		if (targetDate > now) {
			targetDate = new Date(currentYear - 1, parseInt(month) - 1, parseInt(day), 0, 0);
		}

		const timeDiff = now - targetDate;
		const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
		const weekdays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
		const weekday = weekdays[targetDate.getDay()];

		// 如果是今天
		if (daysDiff === 0) {
			return `今天`;
		}

		// 如果是昨天
		if (daysDiff === 1) {
			return `昨天 ${weekday}`;
		}

		if (daysDiff < now.getDay()) {
			return weekday;
		}

		if (daysDiff < now.getDay() + 7) {
			return `${month}/${day} 上${weekday}`;
		}

		// 超過兩周,按週計算
		const weeksDiff = Math.floor(daysDiff / 7) + 1;
		if (weeksDiff < 8) {
			return `${weeksDiff} 周前${weekday}`;
		}

		// 超過 8 周,顯示月/日
		return `${month}/${day}`;
	}

	function escapeAttr(str) {
		return escapeHTML(str);
	}
})();