diff --git a/src/plugins/built-in/globalSearch/lazy.ts b/src/plugins/built-in/globalSearch/lazy.ts index c8005266..8653afaf 100644 --- a/src/plugins/built-in/globalSearch/lazy.ts +++ b/src/plugins/built-in/globalSearch/lazy.ts @@ -5,7 +5,7 @@ import { defineSettings, hotkeySetting, } from "../../core/settingsHelpers"; -import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { isSeqtaEngageExperience, isSeqtaLoginPage } from "@/seqta/utils/isSeqtaEngage"; import styles from "./src/core/styles.css?inline"; import { resetSearchIndexes, @@ -90,7 +90,7 @@ const globalSearchPlugin = defineLazyPlugin({ const runGlobalSearch = globalSearchPlugin.run!; globalSearchPlugin.run = async (api) => { - if (isSeqtaEngageExperience()) return () => {}; + if (isSeqtaEngageExperience() || isSeqtaLoginPage()) return () => {}; // Eager chrome (like Analytics menu injection) — heavy chunk loads behind it. const hotkey = isValidHotkey(api.settings.searchHotkey ?? "") diff --git a/src/plugins/built-in/timetable/CalendarSyncControl.svelte b/src/plugins/built-in/timetable/CalendarSyncControl.svelte index b3729d33..7fb4bfe2 100644 --- a/src/plugins/built-in/timetable/CalendarSyncControl.svelte +++ b/src/plugins/built-in/timetable/CalendarSyncControl.svelte @@ -58,6 +58,7 @@ let modalProvider = $state(null); let showDisconnect = $state(false); let showDeleteEvents = $state(false); + let disconnectOverlay = $state(false); let toast = $state<{ message: string; error: boolean } | null>(null); let syncProgress = $state(null); let syncWeeksAhead = $state(12); @@ -67,20 +68,20 @@ let triggerEl = $state(null); let menuEl = $state(null); let modalEl = $state(null); + let overlayEl = $state(null); let menuStyle = $state(""); let toastTimer: ReturnType | null = null; const isBusy = $derived(busy !== null); const anyConnected = $derived(googleStatus.connected || outlookStatus.connected); const modalOpen = $derived(showDisconnect || showDeleteEvents); - const modalBusy = $derived( - showDisconnect ? busy?.phase === "disconnect" : busy?.phase === "delete", - ); + const modalBusy = $derived(showDisconnect ? busy?.phase === "disconnect" : busy?.phase === "delete"); const providerLabel = $derived(calendarProviderLabel(modalProvider ?? "google")); const showTriggerProgress = $derived( isBusy && (busy?.phase === "sync" || busy?.phase === "delete" || + busy?.phase === "disconnect" || busy?.phase === "connect" || (syncProgress !== null && syncProgress.phase !== "done")), ); @@ -93,7 +94,12 @@ }); const triggerStatusText = $derived.by(() => { if (!showTriggerProgress) return "Sync with Calendar"; - const verb = busy?.phase === "delete" ? "Deleting" : "Syncing"; + const verb = + busy?.phase === "delete" + ? "Deleting" + : busy?.phase === "disconnect" + ? "Disconnecting" + : "Syncing"; if (syncProgress?.total) return `${verb} ${triggerProgressPercent}%`; return `${verb}…`; }); @@ -102,15 +108,27 @@ return anyConnected ? "Calendar sync options" : "Sync with Calendar"; } if (syncProgress?.total) { - const verb = busy?.phase === "delete" ? "Deleting" : "Syncing"; + const verb = + busy?.phase === "delete" + ? "Deleting" + : busy?.phase === "disconnect" + ? "Disconnecting" + : "Syncing"; return `Calendar ${verb.toLowerCase()} in progress, ${triggerProgressPercent} percent complete`; } - return busy?.phase === "delete" - ? "Calendar deletion in progress" - : "Calendar sync in progress"; + if (busy?.phase === "delete") return "Calendar deletion in progress"; + if (busy?.phase === "disconnect") return "Calendar disconnect in progress"; + return "Calendar sync in progress"; }); const accent = "var(--bsplus-cal-accent, var(--better-main, #3b82f6))"; + const overlayProgressPercent = $derived(syncProgressPercent(syncProgress)); + const overlayStatusText = $derived.by(() => { + if (busy?.phase === "disconnect") return "Disconnecting account…"; + if (syncProgress?.message) return syncProgress.message; + return "Removing synced classes…"; + }); + function providerPhase(provider: CalendarProvider): BusyPhase { return busy?.provider === provider ? busy.phase : null; } @@ -225,6 +243,16 @@ } } + async function deleteProviderEvents(provider: CalendarProvider) { + const deleteFn = + provider === "google" + ? deleteSyncedEventsFromGoogleCalendar + : deleteSyncedEventsFromOutlookCalendar; + return deleteFn(location.origin, () => getCalendarAccessToken(provider), { + onProgress: handleSyncProgress, + }); + } + async function confirmDeleteEvents() { if (isBusy || !modalProvider) return; const provider = modalProvider; @@ -238,13 +266,7 @@ message: "Preparing removal…", }; try { - const deleteFn = - provider === "google" - ? deleteSyncedEventsFromGoogleCalendar - : deleteSyncedEventsFromOutlookCalendar; - const result = await deleteFn(location.origin, () => getCalendarAccessToken(provider), { - onProgress: handleSyncProgress, - }); + const result = await deleteProviderEvents(provider); if (!result.success) { showToastMessage(result.error ?? "Could not remove calendar events.", true); @@ -279,22 +301,77 @@ await saveSyncSettings({ autoSyncWeekly: checked }); } - async function confirmDisconnect() { + async function disconnectProvider( + provider: CalendarProvider, + label: string, + toastMessage?: string, + ) { + const result = await disconnectCalendarProvider(provider); + if (!result?.success) { + showToastMessage(`Could not disconnect ${label} Calendar.`, true); + return false; + } + setProviderStatus(provider, { connected: false, lastSyncAt: undefined }); + modalProvider = null; + if (toastMessage) showToastMessage(toastMessage); + return true; + } + + async function confirmDisconnect(deleteEvents: boolean) { if (isBusy || !modalProvider) return; const provider = modalProvider; - busy = { provider, phase: "disconnect" }; const label = calendarProviderLabel(provider); - try { - const result = await disconnectCalendarProvider(provider); - if (!result?.success) { - showToastMessage(`Could not disconnect ${label} Calendar.`, true); - return; - } - setProviderStatus(provider, { connected: false, lastSyncAt: undefined }); + menuOpen = false; + + if (deleteEvents) { showDisconnect = false; - menuOpen = false; - modalProvider = null; - showToastMessage(`Disconnected from ${label} Calendar.`); + disconnectOverlay = true; + busy = { provider, phase: "delete" }; + syncProgress = { + phase: "preparing", + current: 0, + total: 1, + message: "Preparing removal…", + }; + try { + const deleteResult = await deleteProviderEvents(provider); + if (!deleteResult.success) { + showToastMessage(deleteResult.error ?? "Could not remove calendar events.", true); + return; + } + + busy = { provider, phase: "disconnect" }; + syncProgress = { + phase: "preparing", + current: 0, + total: 1, + message: "Disconnecting account…", + }; + + const removed = deleteResult.deleted ?? 0; + const toastMessage = + removed > 0 + ? `Removed ${removed} event${removed === 1 ? "" : "s"} and disconnected from ${label} Calendar.` + : `Disconnected from ${label} Calendar.`; + await disconnectProvider(provider, label, toastMessage); + } catch (err) { + showToastMessage(err instanceof Error ? err.message : "Disconnect failed.", true); + } finally { + syncProgress = null; + busy = null; + disconnectOverlay = false; + } + return; + } + + busy = { provider, phase: "disconnect" }; + try { + const ok = await disconnectProvider( + provider, + label, + `Disconnected from ${label} Calendar.`, + ); + if (ok) showDisconnect = false; } catch (err) { showToastMessage(err instanceof Error ? err.message : "Disconnect failed.", true); } finally { @@ -327,6 +404,7 @@ if (themeHost instanceof HTMLElement) syncCalendarSyncTheme(themeHost); if (menuEl) syncCalendarSyncTheme(menuEl); if (modalEl) syncCalendarSyncTheme(modalEl); + if (overlayEl) syncCalendarSyncTheme(overlayEl); } $effect(() => { @@ -337,6 +415,20 @@ if (modalOpen && modalEl) syncHostTheme(); }); + $effect(() => { + if (disconnectOverlay && overlayEl) syncHostTheme(); + }); + + $effect(() => { + if (!disconnectOverlay) return; + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }); + $effect(() => { if (!menuOpen || !triggerEl) return; updateMenuPosition(); @@ -579,10 +671,10 @@ Disconnect {providerLabel} Calendar?

- Stops BetterSEQTA+ from updating your calendar. Synced classes stay in {providerLabel} Calendar - until you delete them or connect again. + Disconnecting normally removes synced classes from {providerLabel} Calendar and signs you out. + Keep this tab open while that runs. You can also disconnect without deleting your events.

-
+
+
{:else} @@ -631,6 +731,43 @@
{/if} + {#if disconnectOverlay} +
+
+

+ Do not close this tab +

+

+ Removing synced classes and disconnecting {providerLabel} Calendar. Closing SEQTA now may + leave events behind or interrupt sign-out. +

+
+ +
+

{overlayStatusText}

+
+
+ {/if} + {#if toast}
{ if (!document.querySelector(".login")) { observer.disconnect(); @@ -570,7 +568,7 @@ export function tryLoad() { const mode = await waitForEngageLoginOrContent(); if (mode === "login") { finishLoad(); - watchForEngageLogin(); + watchForLoginDismiss(); return; } if (mode === "timeout") { @@ -589,8 +587,15 @@ export function tryLoad() { return; } + if (isSeqtaLoginPage()) { + finishLoad(); + watchForLoginDismiss(); + return; + } + waitForElm(".login").then(() => { finishLoad(); + watchForLoginDismiss(); }); waitForElm(".day-container").then(() => { @@ -702,6 +707,7 @@ export function init() { if (settingsState.onoff) { verboseInfo("[BetterSEQTA+] Enabled"); + const onLogin = isSeqtaLoginPage(); if (settingsState.DarkMode) document.documentElement.classList.add("dark"); if (settingsState.iconOnlySidebar) { if (document.body) { @@ -713,11 +719,13 @@ export function init() { } } - document.querySelector(".legacy-root")?.classList.add("hidden"); + if (!onLogin) { + document.querySelector(".legacy-root")?.classList.add("hidden"); + } // Learn only: hide native sidebar + mount Svelte replacement during loading. // Engage keeps its native React menu — never apply the pending hide class there. - if (!isSeqtaEngageExperience()) { + if (!onLogin && !isSeqtaEngageExperience()) { document.documentElement.classList.add("bsplus-custom-sidebar-pending"); document.documentElement.classList.add("bsplus-custom-title-pending"); void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => { @@ -742,7 +750,9 @@ export function init() { window.addEventListener("hashchange", () => { if (settingsState.adaptiveThemeColour) void updateAllColors(); }); - loading(); + if (!onLogin) { + loading(); + } InjectCustomIcons(); applyMenuItemVisibility(); syncTimetableUrlMonitoring(); diff --git a/src/seqta/ui/sidebar/mountCustomSidebar.ts b/src/seqta/ui/sidebar/mountCustomSidebar.ts index e2f846f8..9d27fa31 100644 --- a/src/seqta/ui/sidebar/mountCustomSidebar.ts +++ b/src/seqta/ui/sidebar/mountCustomSidebar.ts @@ -1,7 +1,7 @@ import { mount, unmount } from "svelte"; import type { SettingsState } from "@/types/storage"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; -import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { isSeqtaEngageExperience, isSeqtaLoginPage } from "@/seqta/utils/isSeqtaEngage"; import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForSeqtaMenu } from "@/seqta/utils/waitForSeqtaShell"; import Sidebar from "./Sidebar.svelte"; @@ -199,7 +199,7 @@ function clearPendingClass() { * and begin mounting the Svelte sidebar as soon as `#menu` exists. */ export function prepareCustomSidebarEarly() { - if (isSeqtaEngageExperience()) return; + if (isSeqtaEngageExperience() || isSeqtaLoginPage()) return; if (!settingsState.onoff) return; if (earlyPrepareStarted) return; diff --git a/src/seqta/ui/titlebar/mountCustomTitleBar.ts b/src/seqta/ui/titlebar/mountCustomTitleBar.ts index 060bc197..1efbad0b 100644 --- a/src/seqta/ui/titlebar/mountCustomTitleBar.ts +++ b/src/seqta/ui/titlebar/mountCustomTitleBar.ts @@ -1,6 +1,6 @@ import { mount, unmount } from "svelte"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; -import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; +import { isSeqtaEngageExperience, isSeqtaLoginPage } from "@/seqta/utils/isSeqtaEngage"; import { waitForSeqtaTitle } from "@/seqta/utils/waitForSeqtaShell"; import TitleBar from "./TitleBar.svelte"; import { titleBarState } from "./titleBarState.svelte"; @@ -46,7 +46,7 @@ function isReady() { /** finishLoad waits here so the overlay stays until the title bar is ready. */ export async function waitForCustomTitleBarReady(timeoutMs = 10000) { - if (isSeqtaEngageExperience() || !settingsState.onoff) { + if (isSeqtaEngageExperience() || isSeqtaLoginPage() || !settingsState.onoff) { document.documentElement.classList.remove(PENDING_CLASS); return; } @@ -85,7 +85,7 @@ function observeHost(host: HTMLElement) { } export function prepareCustomTitleBarEarly() { - if (isSeqtaEngageExperience() || !settingsState.onoff || earlyPrepareStarted) { + if (isSeqtaEngageExperience() || isSeqtaLoginPage() || !settingsState.onoff || earlyPrepareStarted) { return; } earlyPrepareStarted = true; @@ -94,7 +94,7 @@ export function prepareCustomTitleBarEarly() { } export async function mountCustomTitleBar(): Promise { - if (isSeqtaEngageExperience() || !settingsState.onoff) return false; + if (isSeqtaEngageExperience() || isSeqtaLoginPage() || !settingsState.onoff) return false; if (app && titleEl && document.getElementById(ROOT_ID)) { observeHost(titleEl); diff --git a/src/seqta/utils/isSeqtaEngage.ts b/src/seqta/utils/isSeqtaEngage.ts index 727ec982..3c4d563f 100644 --- a/src/seqta/utils/isSeqtaEngage.ts +++ b/src/seqta/utils/isSeqtaEngage.ts @@ -2,3 +2,8 @@ export function isSeqtaEngageExperience(): boolean { return document.title.includes("SEQTA Engage"); } + +/** Unauthenticated SEQTA login shell (Learn + Engage). */ +export function isSeqtaLoginPage(): boolean { + return document.querySelector(".login") !== null; +}