Files
BetterSEQTA-Plus/src/plugins/built-in/timetable/CalendarSyncControl.svelte
T
2026-07-13 16:30:08 +09:30

1072 lines
32 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import type { Snippet } from "svelte";
import { fade, fly } from "svelte/transition";
import browser from "webextension-polyfill";
import {
GOOGLE_CALENDAR_SYNC_WEEKS_MAX,
GOOGLE_CALENDAR_SYNC_WEEKS_MIN,
} from "@/config/googleCalendar";
import { maybeRunDueWeeklySync } from "@/seqta/utils/googleCalendar/calendarSyncListener";
import {
deleteSyncedEventsFromGoogleCalendar,
deleteSyncedEventsFromOutlookCalendar,
} from "@/seqta/utils/calendarSync/syncEngine";
import { formatLessonSyncResultMessage } from "@/seqta/utils/calendarSync/lessonSyncShared";
import { runGoogleCalendarSync, runOutlookCalendarSync } from "@/seqta/utils/calendarSync/syncRunner";
import type {
GoogleCalendarStatus,
GoogleCalendarSyncProgress,
GoogleCalendarSyncResult,
} from "@/seqta/utils/googleCalendar/types";
import type { OutlookCalendarStatus } from "@/seqta/utils/outlookCalendar/storage";
import OutlookCalendarIcon from "./OutlookCalendarIcon.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import {
formatLastSync,
isCalendarSyncModalTarget,
portalToBody,
providerLabel as calendarProviderLabel,
syncCalendarSyncTheme,
syncProgressPercent,
type CalendarProvider,
} from "./calendarSyncUi";
type BusyPhase = "connect" | "sync" | "delete" | "disconnect" | null;
type BusyState = { provider: CalendarProvider; phase: BusyPhase } | null;
type ProviderStatus = { configured: boolean; connected: boolean; lastSyncAt?: number };
function setProviderStatus(provider: CalendarProvider, patch: Partial<ProviderStatus>) {
if (provider === "google") googleStatus = { ...googleStatus, ...patch };
else outlookStatus = { ...outlookStatus, ...patch };
}
function providerStatus(provider: CalendarProvider): ProviderStatus {
return provider === "google" ? googleStatus : outlookStatus;
}
let googleStatus = $state<GoogleCalendarStatus>({ configured: true, connected: false });
let outlookStatus = $state<OutlookCalendarStatus>({ configured: true, connected: false });
let busy = $state<BusyState>(null);
let menuOpen = $state(false);
let modalProvider = $state<CalendarProvider | null>(null);
let showDisconnect = $state(false);
let showDeleteEvents = $state(false);
let toast = $state<{ message: string; error: boolean } | null>(null);
let syncProgress = $state<GoogleCalendarSyncProgress | null>(null);
let syncWeeksAhead = $state(12);
let autoSyncWeekly = $state(true);
let rootEl = $state<HTMLDivElement | null>(null);
let triggerEl = $state<HTMLButtonElement | null>(null);
let menuEl = $state<HTMLDivElement | null>(null);
let menuStyle = $state("");
let toastTimer: ReturnType<typeof setTimeout> | 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 providerLabel = $derived(calendarProviderLabel(modalProvider ?? "google"));
const showTriggerProgress = $derived(
isBusy &&
(busy?.phase === "sync" ||
busy?.phase === "delete" ||
busy?.phase === "connect" ||
(syncProgress !== null && syncProgress.phase !== "done")),
);
const triggerProgressPercent = $derived.by(() => {
if (!showTriggerProgress) return 0;
if (syncProgress && syncProgress.phase !== "done") {
return syncProgressPercent(syncProgress);
}
return 10;
});
const triggerStatusText = $derived.by(() => {
if (!showTriggerProgress) return "Sync with Calendar";
const verb = busy?.phase === "delete" ? "Deleting" : "Syncing";
if (syncProgress?.total) return `${verb} ${triggerProgressPercent}%`;
return `${verb}…`;
});
const triggerAriaLabel = $derived.by(() => {
if (!showTriggerProgress) {
return anyConnected ? "Calendar sync options" : "Sync with Calendar";
}
if (syncProgress?.total) {
const verb = busy?.phase === "delete" ? "Deleting" : "Syncing";
return `Calendar ${verb.toLowerCase()} in progress, ${triggerProgressPercent} percent complete`;
}
return busy?.phase === "delete"
? "Calendar deletion in progress"
: "Calendar sync in progress";
});
const accent = "var(--bsplus-cal-accent, var(--better-main, #3b82f6))";
function providerPhase(provider: CalendarProvider): BusyPhase {
return busy?.provider === provider ? busy.phase : null;
}
function providerStatusText(status: ProviderStatus, notConfigured: string): string {
if (!status.configured) return notConfigured;
if (!status.connected) return "Not connected";
const lastSync = formatLastSync(status.lastSyncAt);
return lastSync ? `Connected · ${lastSync}` : "Connected";
}
function closeModal() {
if (showDisconnect && busy?.phase !== "disconnect") showDisconnect = false;
if (showDeleteEvents && busy?.phase !== "delete") showDeleteEvents = false;
}
function showToastMessage(message: string, isError = false) {
toast = { message, error: isError };
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast = null;
}, 4500);
}
async function refreshStatus() {
const [google, outlook] = await Promise.all([
browser.runtime.sendMessage({ type: "googleCalendarStatus" }) as Promise<GoogleCalendarStatus>,
browser.runtime.sendMessage({ type: "outlookCalendarStatus" }) as Promise<OutlookCalendarStatus>,
]);
googleStatus = google;
outlookStatus = outlook;
syncWeeksAhead = google.syncWeeksAhead ?? 12;
autoSyncWeekly = google.autoSyncWeekly !== false;
}
async function getAccessToken(provider: CalendarProvider): Promise<string> {
const messageType =
provider === "google" ? "googleCalendarGetAccessToken" : "outlookCalendarGetAccessToken";
const res = (await browser.runtime.sendMessage({ type: messageType })) as {
success?: boolean;
accessToken?: string;
error?: string;
};
if (!res?.success || !res.accessToken) {
throw new Error(res?.error ?? "Could not get calendar access token.");
}
return res.accessToken;
}
function handleSyncProgress(progress: GoogleCalendarSyncProgress) {
syncProgress = progress;
}
async function saveSyncSettings(patch: {
syncWeeksAhead?: number;
autoSyncWeekly?: boolean;
}) {
const result = (await browser.runtime.sendMessage({
type: "googleCalendarUpdateSyncSettings",
...patch,
})) as GoogleCalendarStatus & { success?: boolean };
if (result.syncWeeksAhead != null) syncWeeksAhead = result.syncWeeksAhead;
if (result.autoSyncWeekly != null) autoSyncWeekly = result.autoSyncWeekly;
googleStatus = { ...googleStatus, ...result };
}
async function performSync(
provider: CalendarProvider,
mode: "full" | "incremental" = "full",
): Promise<boolean> {
const run = provider === "google" ? runGoogleCalendarSync : runOutlookCalendarSync;
const format = (result: GoogleCalendarSyncResult) =>
formatLessonSyncResultMessage(
result,
`${calendarProviderLabel(provider)} Calendar`,
);
const result = await run({ mode, onProgress: handleSyncProgress });
syncProgress = null;
if (!result.success) {
showToastMessage(result.error ?? "Calendar sync failed.", true);
return false;
}
setProviderStatus(provider, {
connected: true,
lastSyncAt: result.lastSyncAt ?? providerStatus(provider).lastSyncAt,
});
showToastMessage(format(result));
return true;
}
async function connectProvider(provider: CalendarProvider) {
const status = providerStatus(provider);
if (!status.configured || isBusy) return;
menuOpen = false;
busy = { provider, phase: "connect" };
const connectType =
provider === "google" ? "googleCalendarConnect" : "outlookCalendarConnect";
try {
const result = (await browser.runtime.sendMessage({
type: connectType,
})) as GoogleCalendarSyncResult;
if (!result.success) {
showToastMessage(
result.error ?? `Could not connect to ${calendarProviderLabel(provider)} Calendar.`,
true,
);
return;
}
setProviderStatus(provider, { connected: true });
busy = { provider, phase: "sync" };
await performSync(provider);
} catch (err) {
showToastMessage(err instanceof Error ? err.message : "Could not connect.", true);
} finally {
syncProgress = null;
busy = null;
}
}
async function syncProvider(provider: CalendarProvider) {
const status = providerStatus(provider);
if (!status.configured || isBusy) return;
if (!status.connected) {
await connectProvider(provider);
return;
}
busy = { provider, phase: "sync" };
menuOpen = false;
try {
await performSync(provider);
} catch (err) {
showToastMessage(err instanceof Error ? err.message : "Calendar sync failed.", true);
} finally {
syncProgress = null;
busy = null;
}
}
async function confirmDeleteEvents() {
if (isBusy || !modalProvider) return;
const provider = modalProvider;
showDeleteEvents = false;
menuOpen = false;
busy = { provider, phase: "delete" };
syncProgress = {
phase: "preparing",
current: 0,
total: 1,
message: "Preparing removal…",
};
try {
const deleteFn =
provider === "google"
? deleteSyncedEventsFromGoogleCalendar
: deleteSyncedEventsFromOutlookCalendar;
const result = await deleteFn(location.origin, () => getAccessToken(provider), {
onProgress: handleSyncProgress,
});
if (!result.success) {
showToastMessage(result.error ?? "Could not remove calendar events.", true);
return;
}
const removed = result.deleted ?? 0;
modalProvider = null;
const label = calendarProviderLabel(provider);
if (removed === 0) {
showToastMessage("No synced events to remove.");
} else {
showToastMessage(`Removed ${removed} event${removed === 1 ? "" : "s"} from ${label} Calendar.`);
}
} catch (err) {
showToastMessage(err instanceof Error ? err.message : "Remove failed.", true);
} finally {
syncProgress = null;
busy = null;
}
}
async function onWeeksAheadChange(event: Event) {
const value = Number((event.currentTarget as HTMLInputElement).value);
if (!Number.isFinite(value)) return;
await saveSyncSettings({ syncWeeksAhead: value });
}
async function onAutoSyncToggle(event: Event) {
const checked = (event.currentTarget as HTMLInputElement).checked;
autoSyncWeekly = checked;
await saveSyncSettings({ autoSyncWeekly: checked });
}
async function confirmDisconnect() {
if (isBusy || !modalProvider) return;
const provider = modalProvider;
busy = { provider, phase: "disconnect" };
const disconnectType =
provider === "google" ? "googleCalendarDisconnect" : "outlookCalendarDisconnect";
const label = calendarProviderLabel(provider);
try {
const result = (await browser.runtime.sendMessage({
type: disconnectType,
})) as { success?: boolean };
if (!result?.success) {
showToastMessage(`Could not disconnect ${label} Calendar.`, true);
return;
}
setProviderStatus(provider, { connected: false, lastSyncAt: undefined });
showDisconnect = false;
menuOpen = false;
modalProvider = null;
showToastMessage(`Disconnected from ${label} Calendar.`);
} catch (err) {
showToastMessage(err instanceof Error ? err.message : "Disconnect failed.", true);
} finally {
busy = null;
}
}
function openModal(provider: CalendarProvider, kind: "delete" | "disconnect") {
if (isBusy) return;
modalProvider = provider;
menuOpen = false;
showDisconnect = kind === "disconnect";
showDeleteEvents = kind === "delete";
}
function toggleMenu() {
menuOpen = !menuOpen;
if (menuOpen) queueMicrotask(syncHostTheme);
}
function updateMenuPosition() {
if (!triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
menuStyle = `top:${rect.bottom + 8}px;right:${window.innerWidth - rect.right}px;`;
syncHostTheme();
}
function syncHostTheme() {
const themeHost = rootEl?.closest(".bsplus-calendar-sync-mount");
if (themeHost instanceof HTMLElement) syncCalendarSyncTheme(themeHost);
if (menuEl) syncCalendarSyncTheme(menuEl);
}
$effect(() => {
const host = rootEl?.closest(".timetable-calendar-controls");
host?.classList.toggle("bsplus-cal-menu-open", menuOpen);
return () => host?.classList.remove("bsplus-cal-menu-open");
});
$effect(() => {
if (menuOpen && menuEl) syncHostTheme();
});
$effect(() => {
if (!menuOpen || !triggerEl) return;
updateMenuPosition();
const onLayout = () => updateMenuPosition();
window.addEventListener("resize", onLayout);
window.addEventListener("scroll", onLayout, true);
return () => {
window.removeEventListener("resize", onLayout);
window.removeEventListener("scroll", onLayout, true);
};
});
onMount(() => {
void refreshStatus().then(() => {
void maybeRunDueWeeklySync((message, isError) => {
showToastMessage(message, isError);
void refreshStatus();
});
});
const themeKeys = [
"selectedColor",
"selectedFont",
"DarkMode",
"adaptiveThemeColour",
"adaptiveThemeGradient",
"selectedTheme",
] as const;
const onThemeChange = () => syncHostTheme();
for (const key of themeKeys) {
settingsState.register(key, onThemeChange);
}
const themeObserver = new MutationObserver(onThemeChange);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});
const onDocPointer = (event: PointerEvent) => {
if (!menuOpen) return;
const target = event.target as Node;
if (rootEl?.contains(target)) return;
if (menuEl?.contains(target)) return;
if (isCalendarSyncModalTarget(target)) return;
menuOpen = false;
};
document.addEventListener("pointerdown", onDocPointer);
return () => {
document.removeEventListener("pointerdown", onDocPointer);
for (const key of themeKeys) {
settingsState.unregister(key, onThemeChange);
}
themeObserver.disconnect();
if (toastTimer) clearTimeout(toastTimer);
};
});
</script>
{#snippet providerPanel(
provider: CalendarProvider,
status: ProviderStatus,
notConfiguredMsg: string,
icon: Snippet,
name: Snippet,
)}
<div class="bsplus-cal-provider" role="none">
<div class="bsplus-cal-provider-row">
<span class="bsplus-cal-provider-icon" aria-hidden="true">
{@render icon()}
</span>
<div class="bsplus-cal-provider-copy">
<span class="bsplus-cal-provider-name">{@render name()}</span>
<span class="bsplus-cal-provider-status">
{providerStatusText(status, notConfiguredMsg)}
</span>
</div>
</div>
<div class="bsplus-cal-provider-actions">
{#if !status.connected}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={!status.configured || isBusy}
onclick={() => void connectProvider(provider)}
>
{providerPhase(provider) === "connect" ? "Connecting…" : "Connect & sync"}
</button>
{:else}
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--primary"
style:--bsplus-cal-accent={accent}
role="menuitem"
disabled={isBusy}
onclick={() => void syncProvider(provider)}
>
{providerPhase(provider) === "sync" ? "Updating…" : "Update calendar"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => openModal(provider, "delete")}
>
{providerPhase(provider) === "delete" ? "Deleting…" : "Delete synced classes"}
</button>
<button
type="button"
class="bsplus-cal-action bsplus-cal-action--ghost"
role="menuitem"
disabled={isBusy}
onclick={() => openModal(provider, "disconnect")}
>
Disconnect account
</button>
{/if}
</div>
</div>
{/snippet}
<div class="bsplus-cal-sync" bind:this={rootEl}>
<button
type="button"
class="uiButton bsplus-cal-trigger"
bind:this={triggerEl}
class:bsplus-cal-trigger--open={menuOpen}
class:bsplus-cal-trigger--busy={isBusy}
class:bsplus-cal-trigger--progress={showTriggerProgress}
style:--bsplus-cal-trigger-progress="{triggerProgressPercent}%"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-busy={isBusy}
aria-label={triggerAriaLabel}
onclick={() => toggleMenu()}
>
<span class="bsplus-cal-trigger-fill" aria-hidden="true"></span>
<span class="bsplus-cal-trigger-content">
<span class="bsplus-cal-trigger-icon iconFamily" aria-hidden="true">&#xe9cd;</span>
<span class="bsplus-cal-trigger-text">{triggerStatusText}</span>
</span>
</button>
{#if menuOpen}
<div
class="bsplus-cal-menu"
role="menu"
bind:this={menuEl}
style={menuStyle}
use:portalToBody
transition:fly={{ y: -6, duration: 160 }}
>
<div class="bsplus-cal-menu-header">
<span class="bsplus-cal-menu-title">Calendar sync</span>
<span class="bsplus-cal-menu-sub">Copy your SEQTA timetable classes to Google or Outlook</span>
</div>
{@render providerPanel(
"google",
googleStatus,
"Not available in this build",
googleIcon,
googleName,
)}
{@render providerPanel(
"outlook",
outlookStatus,
"Set OUTLOOK_OAUTH_CLIENT_ID to enable",
outlookIcon,
outlookName,
)}
{#if anyConnected}
<div class="bsplus-cal-settings" role="group" aria-label="Sync options">
<label class="bsplus-cal-setting">
<div class="bsplus-cal-setting-copy">
<span class="bsplus-cal-setting-label">Weeks to sync</span>
<span class="bsplus-cal-setting-desc">
How many weeks of classes to add when you connect or tap Update calendar.
</span>
</div>
<input
type="number"
class="bsplus-cal-setting-input"
min={GOOGLE_CALENDAR_SYNC_WEEKS_MIN}
max={GOOGLE_CALENDAR_SYNC_WEEKS_MAX}
value={syncWeeksAhead}
disabled={isBusy}
onchange={(e) => void onWeeksAheadChange(e)}
/>
</label>
<label class="bsplus-cal-setting bsplus-cal-setting--toggle">
<div class="bsplus-cal-setting-copy">
<span class="bsplus-cal-setting-label">Sync new weeks automatically</span>
<span class="bsplus-cal-setting-desc">
Each week, add the next week of your timetable without opening this menu.
</span>
</div>
<input
type="checkbox"
class="bsplus-cal-setting-checkbox"
checked={autoSyncWeekly}
disabled={isBusy}
onchange={(e) => void onAutoSyncToggle(e)}
/>
</label>
</div>
{/if}
</div>
{/if}
{#if modalOpen}
<div
class="bsplus-cal-modal-backdrop"
use:portalToBody
onclick={(e) => {
if (e.target === e.currentTarget && !modalBusy) closeModal();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !modalBusy) closeModal();
}}
role="presentation"
transition:fade={{ duration: 150 }}
>
<div
class="bsplus-cal-modal"
role="dialog"
aria-modal="true"
aria-labelledby="bsplus-cal-modal-title"
transition:fade={{ duration: 180 }}
>
{#if showDisconnect}
<h2 id="bsplus-cal-modal-title" class="bsplus-cal-modal-title">
Disconnect {providerLabel} Calendar?
</h2>
<p class="bsplus-cal-modal-body">
Stops BetterSEQTA+ from updating your calendar. Synced classes stay in {providerLabel} Calendar
until you delete them or connect again.
</p>
<div class="bsplus-cal-modal-actions">
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={modalBusy}
onclick={closeModal}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={modalBusy}
onclick={() => void confirmDisconnect()}
>
{modalBusy ? "Disconnecting…" : "Disconnect account"}
</button>
</div>
{:else}
<h2 id="bsplus-cal-modal-title" class="bsplus-cal-modal-title">
Delete synced classes?
</h2>
<p class="bsplus-cal-modal-body">
Removes every BetterSEQTA+ timetable event from your {providerLabel} Calendar for this school.
Your account stays connected — use Update calendar to sync again.
</p>
<div class="bsplus-cal-modal-actions">
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={modalBusy}
onclick={closeModal}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={modalBusy}
onclick={() => void confirmDeleteEvents()}
>
{modalBusy ? "Deleting…" : "Delete synced classes"}
</button>
</div>
{/if}
</div>
</div>
{/if}
{#if toast}
<div
class="bsplus-cal-toast"
class:bsplus-cal-toast--error={toast.error}
role="status"
transition:fade={{ duration: 150 }}
>
{toast.message}
</div>
{/if}
</div>
{#snippet googleIcon()}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="-3 0 262 262"
preserveAspectRatio="xMidYMid"
aria-hidden="true"
>
<path
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
fill="#4285F4"
/>
<path
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
fill="#34A853"
/>
<path
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
fill="#FBBC05"
/>
<path
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
fill="#EB4335"
/>
</svg>
{/snippet}
{#snippet googleName()}
Google Calendar
{/snippet}
{#snippet outlookIcon()}
<OutlookCalendarIcon />
{/snippet}
{#snippet outlookName()}
Outlook Calendar
{/snippet}
<style>
.bsplus-cal-sync {
position: relative;
display: inline-flex;
font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif);
color: var(--bsplus-cal-text, var(--text-primary, #111));
}
.bsplus-cal-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: auto;
height: auto;
padding: 0 10px;
margin-left: 4px;
border-radius: 16px !important;
font-family: inherit;
transition: transform 0.2s ease, opacity 0.2s ease;
overflow: hidden;
isolation: isolate;
}
.bsplus-cal-trigger-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--bsplus-cal-trigger-progress, 0%);
border-radius: inherit;
background: color-mix(
in srgb,
var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 38%,
transparent
);
transition: width 0.25s ease;
pointer-events: none;
}
.bsplus-cal-trigger-content {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.bsplus-cal-trigger-icon {
font-family: "IconFamily" !important;
font-size: 16px;
line-height: 1;
opacity: 0.9;
}
.bsplus-cal-trigger-text {
font-family: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.bsplus-cal-trigger:hover:not(.bsplus-cal-trigger--busy) {
transform: scale(1.03);
}
.bsplus-cal-trigger:active:not(.bsplus-cal-trigger--busy) {
transform: scale(0.97);
}
.bsplus-cal-trigger--open {
background: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 14%, transparent) !important;
}
.bsplus-cal-trigger--busy {
opacity: 0.95;
}
.bsplus-cal-trigger--progress {
cursor: default;
}
.bsplus-cal-trigger--progress:hover,
.bsplus-cal-trigger--progress:active {
transform: none;
}
.bsplus-cal-settings {
margin: 8px 0 10px;
padding: 10px;
border-radius: 10px;
border: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent));
background: color-mix(in srgb, var(--bsplus-cal-surface, #fff) 92%, transparent);
display: grid;
gap: 8px;
}
.bsplus-cal-setting {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
font-size: 12px;
}
.bsplus-cal-setting-copy {
display: grid;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
}
.bsplus-cal-setting-label {
font-weight: 600;
color: var(--bsplus-cal-text, var(--text-primary, #111));
}
.bsplus-cal-setting-desc {
font-size: 10px;
line-height: 1.4;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 58%, transparent);
}
.bsplus-cal-setting--toggle {
align-items: center;
}
.bsplus-cal-setting-input {
width: 64px;
flex: 0 0 auto;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 18%, transparent));
background: var(--bsplus-cal-surface, #fff);
color: var(--bsplus-cal-text, var(--text-primary, #111));
font-size: 12px;
text-align: center;
}
.bsplus-cal-setting-checkbox {
width: 16px;
height: 16px;
flex: 0 0 auto;
accent-color: var(--bsplus-cal-accent, var(--better-main, #3b82f6));
}
.bsplus-cal-menu {
position: fixed;
z-index: var(--bsplus-cal-z-menu, 2147483646);
width: min(320px, calc(100vw - 24px));
padding: 10px;
border-radius: 14px;
background: var(--bsplus-cal-surface, #fff);
color: var(--bsplus-cal-text, #18181b);
border: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent));
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22);
font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif);
}
.bsplus-cal-menu.dark {
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
}
.bsplus-cal-menu-header {
padding: 6px 8px 10px;
border-bottom: 1px solid var(--bsplus-cal-border, color-mix(in srgb, var(--bsplus-cal-text) 10%, transparent));
margin-bottom: 8px;
}
.bsplus-cal-menu-title {
display: block;
font-size: 13px;
font-weight: 700;
}
.bsplus-cal-menu-sub {
display: block;
margin-top: 2px;
font-size: 11px;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 62%, transparent);
}
.bsplus-cal-provider {
padding: 8px;
border-radius: 10px;
background: var(--bsplus-cal-surface-muted, color-mix(in srgb, var(--bsplus-cal-text) 4%, transparent));
margin-bottom: 8px;
}
.bsplus-cal-provider-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.bsplus-cal-provider-icon {
display: flex;
flex: 0 0 auto;
width: 28px;
height: 28px;
}
.bsplus-cal-provider-icon svg {
width: 100%;
height: 100%;
}
.bsplus-cal-provider-copy {
min-width: 0;
}
.bsplus-cal-provider-name {
display: block;
font-size: 13px;
font-weight: 600;
}
.bsplus-cal-provider-status {
display: block;
margin-top: 1px;
font-size: 11px;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 62%, transparent);
}
.bsplus-cal-provider-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.bsplus-cal-action {
flex: 1 1 auto;
min-width: 0;
padding: 7px 10px;
border: none;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-cal-action:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.bsplus-cal-action--primary {
background: var(--bsplus-cal-accent, var(--better-main, #3b82f6));
color: #fff;
}
.bsplus-cal-action--primary:hover:not(:disabled) {
filter: brightness(1.06);
transform: scale(1.02);
}
.bsplus-cal-action--ghost {
background: var(--bsplus-cal-surface-muted, color-mix(in srgb, var(--bsplus-cal-text) 8%, transparent));
color: var(--bsplus-cal-text, #18181b);
}
.bsplus-cal-action--ghost:hover:not(:disabled) {
background: color-mix(in srgb, var(--bsplus-cal-text) 14%, var(--bsplus-cal-surface));
}
.bsplus-cal-modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--bsplus-cal-z-modal, 2147483647);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.bsplus-cal-modal {
width: min(100%, 400px);
padding: 20px;
border-radius: 16px;
background: var(--bsplus-cal-surface, #fff);
color: var(--bsplus-cal-text, #111);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.22);
border: 1px solid color-mix(in srgb, var(--bsplus-cal-text, #111) 12%, transparent);
}
.bsplus-cal-modal-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
}
.bsplus-cal-modal-body {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.5;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 72%, transparent);
}
.bsplus-cal-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.bsplus-cal-btn {
padding: 8px 14px;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.bsplus-cal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bsplus-cal-btn--ghost {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 8%, transparent);
color: var(--bsplus-cal-text, #111);
}
.bsplus-cal-btn--ghost:hover:not(:disabled) {
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 14%, transparent);
}
.bsplus-cal-btn--danger {
background: #dc2626;
color: #fff;
}
.bsplus-cal-btn--danger:hover:not(:disabled) {
background: #b91c1c;
}
.bsplus-cal-toast {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 100000;
max-width: min(360px, calc(100vw - 32px));
padding: 12px 14px;
border-radius: 12px;
background: rgba(20, 20, 20, 0.92);
color: #fff;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
pointer-events: none;
}
.bsplus-cal-toast--error {
background: rgba(120, 24, 24, 0.95);
}
</style>