diff --git a/src/plugins/built-in/timetableEdit/index.ts b/src/plugins/built-in/timetableEdit/index.ts new file mode 100644 index 00000000..0adefb62 --- /dev/null +++ b/src/plugins/built-in/timetableEdit/index.ts @@ -0,0 +1,338 @@ +import type { Plugin } from "../../core/types"; +import { waitForElm } from "@/seqta/utils/waitForElm"; +import styles from "./styles.css?inline"; + +interface TimetableEntryData { + ci: number; + description: string; + room: string; + staff: string; +} + +interface TimetableOverrides { + [ci: string]: { room?: string; staff?: string }; +} + +interface TimetableOverridesBySubject { + [description: string]: { room?: string; staff?: string }; +} + +interface TimetableStorage { + timetableOverrides?: TimetableOverrides; + timetableOverridesBySubject?: TimetableOverridesBySubject; +} + +/** SEQTA timetable entries use .teacher and .room as direct children, and data-instance for ci */ +function getRoomAndTeacherElements(entry: HTMLElement): { + roomEl: HTMLElement | null; + teacherEl: HTMLElement | null; +} { + const roomEl = entry.querySelector(".room") as HTMLElement | null; + const teacherEl = entry.querySelector(".teacher") as HTMLElement | null; + return { roomEl, teacherEl }; +} + +const EDIT_ICON_SVG = + ''; + +function showEditModal( + item: TimetableEntryData, + overrides: TimetableOverrides | undefined, + overridesBySubject: TimetableOverridesBySubject | undefined, + onSave: ( + ci: number, + room: string, + staff: string, + applyToFuture: boolean, + ) => void, + onClear: (ci: number) => void, +): void { + const overlay = document.createElement("div"); + overlay.className = "timetable-edit-modal-overlay"; + + const modal = document.createElement("div"); + modal.className = "timetable-edit-modal"; + + const override = overrides?.[String(item.ci)] ?? overridesBySubject?.[item.description]; + + const roomValue = override?.room ?? item.room ?? ""; + const staffValue = override?.staff ?? item.staff ?? ""; + + const escapeHtml = (s: string) => + s.replace(/&/g, "&").replace(/Edit ${title} + + + + +
+ + +
+
+ ${override ? '' : ""} + + +
+ `; + + overlay.appendChild(modal); + + const removeModal = () => { + overlay.remove(); + document.removeEventListener("keydown", handleKeydown); + }; + + const handleKeydown = (e: KeyboardEvent) => { + if (e.key === "Escape") removeModal(); + }; + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) removeModal(); + }); + + modal.addEventListener("click", (e) => e.stopPropagation()); + modal.addEventListener("mousedown", (e) => e.stopPropagation()); + modal.addEventListener("mouseup", (e) => e.stopPropagation()); + + const roomInput = modal.querySelector("#timetable-edit-room") as HTMLInputElement; + const staffInput = modal.querySelector("#timetable-edit-staff") as HTMLInputElement; + const applyFutureCheckbox = modal.querySelector("#timetable-edit-apply-future") as HTMLInputElement; + + modal.querySelector(".timetable-edit-btn-save")?.addEventListener("click", () => { + onSave( + item.ci, + roomInput.value.trim(), + staffInput.value.trim(), + applyFutureCheckbox?.checked ?? false, + ); + removeModal(); + }); + + modal.querySelector(".timetable-edit-btn-cancel")?.addEventListener("click", removeModal); + + const clearBtn = modal.querySelector(".timetable-edit-btn-clear"); + if (clearBtn) { + clearBtn.addEventListener("click", () => { + onClear(item.ci); + removeModal(); + }); + } + + document.body.appendChild(overlay); + document.addEventListener("keydown", handleKeydown); + roomInput?.focus(); +} + +const timetableEditPlugin: Plugin<{}, TimetableStorage> = { + id: "timetableEdit", + name: "Edit Rooms & Teachers", + description: "Edit room and teacher names in timetable classes", + version: "1.0.0", + settings: {}, + disableToggle: true, + defaultEnabled: true, + + run: async (api) => { + const styleEl = document.createElement("style"); + styleEl.textContent = styles; + document.head.appendChild(styleEl); + + await api.storage.loaded; + + let observer: MutationObserver | null = null; + let quickbarObserver: MutationObserver | null = null; + let lastClickedCi: number | null = null; + let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null; + + const getOverrides = (): TimetableOverrides => + api.storage.timetableOverrides ?? {}; + const getOverridesBySubject = (): TimetableOverridesBySubject => + api.storage.timetableOverridesBySubject ?? {}; + + const getEffectiveOverride = ( + ci: number, + description: string, + ): { room?: string; staff?: string } | undefined => + getOverrides()[String(ci)] ?? getOverridesBySubject()[description]; + + const processEntry = (entry: HTMLElement): void => { + if (entry.classList.contains("assessment") || entry.hasAttribute("data-timetable-edit-processed")) return; + + const ciStr = entry.getAttribute("data-instance"); + if (!ciStr) return; + + const ci = parseInt(ciStr, 10); + if (isNaN(ci)) return; + + const { roomEl, teacherEl } = getRoomAndTeacherElements(entry); + if (!roomEl && !teacherEl) return; + + const titleEl = entry.querySelector(".title"); + const description = titleEl?.textContent?.trim() ?? ""; + const room = roomEl?.textContent?.trim() ?? ""; + const staff = teacherEl?.textContent?.trim() ?? ""; + + const item: TimetableEntryData = { ci, description, room, staff }; + + entry.setAttribute("data-timetable-edit-processed", "true"); + + const override = getEffectiveOverride(ci, description); + if (override) { + if (override.room !== undefined && roomEl) roomEl.textContent = override.room; + if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff; + } + + const captureClick = (e: MouseEvent) => { + lastClickedCi = ci; + lastClickedEntry = { roomEl, teacherEl, item }; + }; + entry.addEventListener("click", captureClick, true); + }; + + const processAllEntries = () => { + document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => { + processEntry(entry as HTMLElement); + }); + }; + + const addEditButtonToQuickbar = (quickbar: HTMLElement) => { + if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return; + + const actions = quickbar.querySelector(".actions"); + if (!actions) return; + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "uiButton timetable-edit-quickbar-btn"; + btn.title = "Edit room and teacher"; + btn.innerHTML = EDIT_ICON_SVG; + + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const ci = lastClickedCi; + const entryData = lastClickedEntry; + if (!ci || !entryData) return; + + const qb = (e.currentTarget as HTMLElement).closest(".quickbar"); + if (!qb) return; + const quickbarRoom = qb.querySelector(".meta .room")?.textContent?.trim() ?? ""; + const quickbarTeacher = qb.querySelector(".meta .teacher")?.textContent?.trim() ?? ""; + const quickbarTitle = qb.querySelector(".title")?.textContent?.trim() ?? ""; + const item: TimetableEntryData = { + ci, + description: quickbarTitle || entryData.item.description, + room: quickbarRoom || entryData.item.room, + staff: quickbarTeacher || entryData.item.staff, + }; + + showEditModal( + item, + getOverrides(), + getOverridesBySubject(), + (ci, room, staff, applyToFuture) => { + if (applyToFuture) { + const bySubject = { ...getOverridesBySubject() }; + bySubject[item.description] = { + room: room || undefined, + staff: staff || undefined, + }; + api.storage.timetableOverridesBySubject = bySubject; + } else { + const current = getOverrides(); + api.storage.timetableOverrides = { + ...current, + [String(ci)]: { room: room || undefined, staff: staff || undefined }, + }; + } + if (entryData.roomEl) entryData.roomEl.textContent = room; + if (entryData.teacherEl) entryData.teacherEl.textContent = staff; + processAllEntries(); + }, + (ci) => { + const current = getOverrides(); + delete current[String(ci)]; + api.storage.timetableOverrides = current; + const bySubject = getOverridesBySubject(); + delete bySubject[item.description]; + api.storage.timetableOverridesBySubject = bySubject; + if (entryData.roomEl) entryData.roomEl.textContent = item.room; + if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff; + processAllEntries(); + }, + ); + }); + + actions.insertBefore(btn, actions.firstChild); + }; + + const syncQuickbarFromDOM = () => { + const quickbar = document.querySelector(".timetablepage .quickbar.visible"); + if (quickbar && quickbar.getAttribute("data-type") === "class") { + const titleEl = quickbar.querySelector(".title"); + const roomEl = quickbar.querySelector(".meta .room"); + const teacherEl = quickbar.querySelector(".meta .teacher"); + if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) { + addEditButtonToQuickbar(quickbar as HTMLElement); + } + } + }; + + const setupQuickbarObserver = () => { + const timetablePage = document.querySelector(".timetablepage"); + if (!timetablePage || quickbarObserver) return; + + quickbarObserver = new MutationObserver(() => { + const quickbar = document.querySelector(".timetablepage .quickbar.visible"); + if (quickbar?.getAttribute("data-type") === "class") { + addEditButtonToQuickbar(quickbar as HTMLElement); + } + }); + + quickbarObserver.observe(timetablePage, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["class"], + }); + }; + + const handleTimetable = async () => { + await waitForElm(".timetablepage .entry", true, 10, 100); + processAllEntries(); + setupQuickbarObserver(); + syncQuickbarFromDOM(); + + const timetablePage = document.querySelector(".timetablepage"); + if (timetablePage && !observer) { + observer = new MutationObserver(() => { + document.querySelectorAll(".timetablepage .entry.class").forEach((entry) => { + if (!entry.hasAttribute("data-timetable-edit-processed")) { + processEntry(entry as HTMLElement); + } + }); + }); + observer.observe(timetablePage, { childList: true, subtree: true }); + } + }; + + const { unregister } = api.seqta.onMount(".timetablepage", handleTimetable); + + return () => { + unregister(); + observer?.disconnect(); + quickbarObserver?.disconnect(); + styleEl.remove(); + document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => { + el.removeAttribute("data-timetable-edit-processed"); + }); + document.querySelectorAll(".timetable-edit-quickbar-btn").forEach((el) => el.remove()); + }; + }, +}; + +export default timetableEditPlugin; diff --git a/src/plugins/built-in/timetableEdit/styles.css b/src/plugins/built-in/timetableEdit/styles.css new file mode 100644 index 00000000..ef686962 --- /dev/null +++ b/src/plugins/built-in/timetableEdit/styles.css @@ -0,0 +1,188 @@ +/* Timetable Edit Plugin - BetterSEQTA Plus style */ + +/* Edit button in quickbar */ +.timetable-edit-quickbar-btn { + padding: 0; + margin: 0; + background: transparent !important; + border: none !important; + cursor: pointer; + transition: all 0.2s ease-in-out; + display: flex; + align-items: center; + justify-content: center; +} + +.timetable-edit-quickbar-btn:hover { + transform: scale(1.05); +} + +.timetable-edit-quickbar-btn:active { + transform: scale(0.95); +} + +.timetable-edit-quickbar-btn svg { + fill: currentColor; + width: 24px; + height: 24px; +} + +/* Edit modal animations */ +@keyframes timetable-edit-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes timetable-edit-modal-in { + from { + opacity: 0; + transform: scale(0.95) translateY(-8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Edit modal overlay - fix click-through with proper stacking */ +.timetable-edit-modal-overlay { + position: fixed; + inset: 0; + z-index: 2147483647; + display: flex; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + pointer-events: auto; + animation: timetable-edit-overlay-in 0.2s ease-out forwards; +} + +.timetable-edit-modal { + padding: 1rem 1.5rem; + margin: 0 1rem; + min-width: 18rem; + max-width: 24rem; + width: 100%; + box-sizing: border-box; + background: var(--background-primary); + border-radius: 0.75rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + pointer-events: auto; + border: 1px solid var(--background-secondary); + animation: timetable-edit-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +.timetable-edit-modal h3 { + margin: 0 0 1rem 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.timetable-edit-modal label { + display: block; + margin-bottom: 0.25rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + opacity: 0.8; +} + +.timetable-edit-modal input[type="text"] { + width: 100%; + min-width: 0; + padding: 0.5rem 1rem 0.5rem 0.75rem; + margin-bottom: 1rem; + font-size: 0.875rem; + border: 1px solid var(--background-secondary); + border-radius: 0.5rem; + background: var(--background-secondary); + color: var(--text-primary); + box-sizing: border-box; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease; + user-select: text; + -webkit-user-select: text; +} + +.timetable-edit-modal input[type="text"]:focus { + outline: none; + border-color: var(--better-main, #007bff); + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + +.timetable-edit-modal-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.timetable-edit-modal-checkbox input { + width: auto; + margin: 0; +} + +.timetable-edit-modal-checkbox label { + margin: 0; + cursor: pointer; +} + +.timetable-edit-modal-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1rem; + flex-wrap: wrap; +} + +.timetable-edit-modal-actions button { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.timetable-edit-modal-actions .timetable-edit-btn-clear { + background: transparent; + border: 1px solid var(--background-secondary); + color: var(--text-primary); +} + +.timetable-edit-modal-actions .timetable-edit-btn-clear:hover { + background: var(--background-secondary); + transform: translateY(-1px); +} + +.timetable-edit-modal-actions .timetable-edit-btn-cancel { + background: transparent; + border: 1px solid var(--background-secondary); + color: var(--text-primary); +} + +.timetable-edit-modal-actions .timetable-edit-btn-cancel:hover { + background: var(--background-secondary); + transform: translateY(-1px); +} + +.timetable-edit-modal-actions .timetable-edit-btn-save { + background: var(--better-main, #007bff); + border: none; + color: var(--text-color, white); +} + +.timetable-edit-modal-actions .timetable-edit-btn-save:hover { + transform: scale(1.03) translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.35); +} + +.timetable-edit-modal-actions .timetable-edit-btn-save:active { + transform: scale(0.98) translateY(0); + box-shadow: none; +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 0c4751c0..0648accb 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -2,6 +2,7 @@ import { PluginManager } from "./core/manager"; // Lightweight plugins (load immediately) import timetablePlugin from "./built-in/timetable"; +import timetableEditPlugin from "./built-in/timetableEdit"; import notificationCollectorPlugin from "./built-in/notificationCollector"; import themesPlugin from "./built-in/themes"; import animatedBackgroundPlugin from "./built-in/animatedBackground"; @@ -23,6 +24,7 @@ pluginManager.registerPlugin(animatedBackgroundPlugin); pluginManager.registerPlugin(assessmentsAveragePlugin); pluginManager.registerPlugin(notificationCollectorPlugin); pluginManager.registerPlugin(timetablePlugin); +pluginManager.registerPlugin(timetableEditPlugin); pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(backgroundMusicPlugin);