Merge remote-tracking branch 'origin/main' into various-bugfixes

This commit is contained in:
2026-07-19 09:03:50 +09:30
59 changed files with 3495 additions and 3113 deletions
@@ -1,142 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
let {
open = false,
busy = false,
providerLabel = "Google",
onConfirm,
onCancel,
} = $props<{
open?: boolean;
busy?: boolean;
providerLabel?: string;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}>();
</script>
{#if open}
<div
class="bsplus-cal-modal-backdrop"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onCancel();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !busy) onCancel();
}}
role="presentation"
transition:fade={{ duration: 150 }}
>
<div
class="bsplus-cal-modal"
role="dialog"
aria-modal="true"
aria-labelledby="bsplus-cal-delete-title"
transition:fade={{ duration: 180 }}
>
<h2 id="bsplus-cal-delete-title" class="bsplus-cal-modal-title">
Remove synced events?
</h2>
<p class="bsplus-cal-modal-body">
This removes all BetterSEQTA+ timetable events from your {providerLabel} Calendar for this school.
Your connection stays active — you can sync again later.
</p>
<div class="bsplus-cal-modal-actions">
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={busy}
onclick={onCancel}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={busy}
onclick={() => void onConfirm()}
>
{busy ? "Removing…" : "Remove from calendar"}
</button>
</div>
</div>
</div>
{/if}
<style>
.bsplus-cal-modal-backdrop {
position: fixed;
inset: 0;
z-index: 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;
}
</style>
@@ -1,142 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
let {
open = false,
busy = false,
providerLabel = "Google",
onConfirm,
onCancel,
} = $props<{
open?: boolean;
busy?: boolean;
providerLabel?: string;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}>();
</script>
{#if open}
<div
class="bsplus-cal-modal-backdrop"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onCancel();
}}
onkeydown={(e) => {
if (e.key === "Escape" && !busy) onCancel();
}}
role="presentation"
transition:fade={{ duration: 150 }}
>
<div
class="bsplus-cal-modal"
role="dialog"
aria-modal="true"
aria-labelledby="bsplus-cal-disconnect-title"
transition:fade={{ duration: 180 }}
>
<h2 id="bsplus-cal-disconnect-title" class="bsplus-cal-modal-title">
Disconnect {providerLabel} Calendar?
</h2>
<p class="bsplus-cal-modal-body">
Your synced timetable events will stay in {providerLabel} Calendar, but BetterSEQTA+ will stop
updating them until you connect again.
</p>
<div class="bsplus-cal-modal-actions">
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--ghost"
disabled={busy}
onclick={onCancel}
>
Cancel
</button>
<button
type="button"
class="bsplus-cal-btn bsplus-cal-btn--danger"
disabled={busy}
onclick={() => void onConfirm()}
>
{busy ? "Disconnecting…" : "Disconnect"}
</button>
</div>
</div>
</div>
{/if}
<style>
.bsplus-cal-modal-backdrop {
position: fixed;
inset: 0;
z-index: 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;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,67 +0,0 @@
<script lang="ts">
import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types";
let {
progress = null,
} = $props<{
progress?: GoogleCalendarSyncProgress | null;
}>();
const percent = $derived(
progress && progress.total > 0
? Math.min(100, Math.round((progress.current / progress.total) * 100))
: progress?.phase === "preparing"
? 8
: 0,
);
</script>
{#if progress && progress.phase !== "done"}
<div class="bsplus-cal-progress" role="status" aria-live="polite" aria-busy="true">
<div class="bsplus-cal-progress-label">{progress.message}</div>
<div class="bsplus-cal-progress-track" aria-hidden="true">
<div class="bsplus-cal-progress-bar" style:width={`${percent}%`}></div>
</div>
{#if progress.total > 0}
<div class="bsplus-cal-progress-meta">{progress.current} / {progress.total}</div>
{/if}
</div>
{/if}
<style>
.bsplus-cal-progress {
margin-top: 8px;
padding: 10px;
border-radius: 10px;
background: color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--bsplus-cal-accent, var(--better-main, #3b82f6)) 22%, transparent);
}
.bsplus-cal-progress-label {
font-size: 11px;
font-weight: 600;
color: var(--bsplus-cal-text, var(--text-primary, #111));
margin-bottom: 8px;
}
.bsplus-cal-progress-track {
height: 8px;
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--bsplus-cal-text, #111) 10%, transparent);
}
.bsplus-cal-progress-bar {
height: 100%;
border-radius: 999px;
background: var(--bsplus-cal-accent, var(--better-main, #3b82f6));
transition: width 0.25s ease;
}
.bsplus-cal-progress-meta {
margin-top: 6px;
font-size: 10px;
color: color-mix(in srgb, var(--bsplus-cal-text, #111) 62%, transparent);
text-align: right;
}
</style>
@@ -11,14 +11,18 @@
}
.timetable-calendar-controls.bsplus-cal-menu-open {
z-index: 2147483646;
z-index: 2147483645;
}
.timetablepage #toolbar:has(.bsplus-cal-menu-open) {
z-index: 2147483646 !important;
z-index: 2147483645 !important;
}
:root {
--bsplus-cal-z-menu: 2147483646;
--bsplus-cal-z-modal: 2147483647;
}
.bsplus-calendar-sync-mount {
display: inline-flex;
font-family: var(--bsplus-cal-font-family, var(--betterseqta-font-family, Rubik), sans-serif);
}
@@ -1,88 +0,0 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor";
import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager";
import { getFontPreset } from "@/seqta/ui/fonts/presets";
export const CALENDAR_THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
export function isCalendarDarkMode(): boolean {
return !!settingsState.DarkMode || document.documentElement.classList.contains("dark");
}
function resolvePageAccentColor(): string {
const computed = getComputedStyle(document.documentElement);
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) return solid;
}
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) return solid;
}
return "#3b82f6";
}
/** Sync extension theme (including dark mode) onto a calendar UI host or portaled menu. */
export function syncCalendarSyncTheme(target: HTMLElement): void {
const computed = getComputedStyle(document.documentElement);
const dark = isCalendarDarkMode();
const fontPreset = getFontPreset(settingsState.selectedFont);
ensureFontLoaded(fontPreset);
target.style.setProperty("--bsplus-cal-font-family", fontPreset.stack);
for (const name of CALENDAR_THEME_CSS_VARS) {
const value =
document.documentElement.style.getPropertyValue(name).trim() ||
computed.getPropertyValue(name).trim();
if (value) target.style.setProperty(name, value);
}
const accent = resolvePageAccentColor();
target.style.setProperty("--bsplus-cal-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle("dark", dark);
const textPrimary =
computed.getPropertyValue("--text-primary").trim() ||
computed.getPropertyValue("--text-color").trim();
const bgPrimary =
computed.getPropertyValue("--background-primary").trim() ||
computed.getPropertyValue("--background-secondary").trim() ||
computed.getPropertyValue("--theme-offset-bg").trim();
target.style.setProperty(
"--bsplus-cal-text",
textPrimary || (dark ? "#f4f4f5" : "#18181b"),
);
target.style.setProperty(
"--bsplus-cal-surface",
bgPrimary || (dark ? "#27272a" : "#ffffff"),
);
target.style.setProperty(
"--bsplus-cal-surface-muted",
dark ? "#3f3f46" : "color-mix(in srgb, var(--bsplus-cal-text) 5%, var(--bsplus-cal-surface))",
);
target.style.setProperty(
"--bsplus-cal-border",
dark ? "color-mix(in srgb, #ffffff 14%, transparent)" : "color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)",
);
}
@@ -1,14 +1,133 @@
import { mount, unmount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { extractSolidColor } from "@/seqta/ui/colors/parseCssColor";
import { ensureFontLoaded } from "@/seqta/ui/fonts/Manager";
import { getFontPreset } from "@/seqta/ui/fonts/presets";
import type { GoogleCalendarSyncProgress } from "@/seqta/utils/googleCalendar/types";
import CalendarSyncControl from "./CalendarSyncControl.svelte";
import { syncCalendarSyncTheme } from "./calendarSyncTheme";
import { registerCalendarContentHandlers } from "@/seqta/utils/googleCalendar/calendarSyncListener";
import hostStyles from "./calendarSyncHost.css?inline";
export type CalendarProvider = "google" | "outlook";
export function providerLabel(provider: CalendarProvider): string {
return provider === "google" ? "Google" : "Outlook";
}
export function formatLastSync(ts?: number): string | null {
if (!ts) return null;
const diff = Date.now() - ts;
if (diff < 60_000) return "Synced just now";
if (diff < 3_600_000) return `Synced ${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `Synced ${Math.floor(diff / 3_600_000)}h ago`;
return `Synced ${new Date(ts).toLocaleDateString()}`;
}
export function syncProgressPercent(progress: GoogleCalendarSyncProgress | null): number {
if (!progress || progress.phase === "done") return 0;
if (progress.total > 0) {
return Math.min(100, Math.round((progress.current / progress.total) * 100));
}
return progress.phase === "preparing" ? 8 : 0;
}
const CONTROLS_CLASS = "timetable-calendar-controls";
const HOST_STYLE_ID = "bsplus-calendar-sync-host-styles";
const THEME_CSS_VARS = [
"--better-main",
"--better-pale",
"--better-light",
"--text-color",
"--background-primary",
"--background-secondary",
"--text-primary",
"--theme-offset-bg",
"--better-sub",
] as const;
const ACCENT_CSS_VARS = [
"--better-main",
"--accent-color-value",
"--accentColor",
"--colour-betterseqta-blue",
] as const;
let currentApp: ReturnType<typeof mount> | null = null;
let mountRoot: HTMLElement | null = null;
export function portalToBody(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
export function isCalendarSyncModalTarget(target: EventTarget | null): boolean {
return target instanceof Element && Boolean(target.closest(".bsplus-cal-modal-backdrop"));
}
/** Sync extension theme (including dark mode) onto a calendar UI host or portaled menu. */
export function syncCalendarSyncTheme(target: HTMLElement): void {
const computed = getComputedStyle(document.documentElement);
const dark = !!settingsState.DarkMode || document.documentElement.classList.contains("dark");
const fontPreset = getFontPreset(settingsState.selectedFont);
ensureFontLoaded(fontPreset);
target.style.setProperty("--bsplus-cal-font-family", fontPreset.stack);
for (const name of THEME_CSS_VARS) {
const value =
document.documentElement.style.getPropertyValue(name).trim() ||
computed.getPropertyValue(name).trim();
if (value) target.style.setProperty(name, value);
}
let accent = "#3b82f6";
for (const name of ACCENT_CSS_VARS) {
const solid = extractSolidColor(computed.getPropertyValue(name));
if (solid) {
accent = solid;
break;
}
}
if (accent === "#3b82f6") {
const fromSettings = settingsState.selectedColor?.trim();
if (fromSettings) {
const solid = extractSolidColor(fromSettings);
if (solid) accent = solid;
}
}
target.style.setProperty("--bsplus-cal-accent", accent);
target.style.setProperty("--better-main", accent);
target.classList.toggle("dark", dark);
const textPrimary =
computed.getPropertyValue("--text-primary").trim() ||
computed.getPropertyValue("--text-color").trim();
const bgPrimary =
computed.getPropertyValue("--background-primary").trim() ||
computed.getPropertyValue("--background-secondary").trim() ||
computed.getPropertyValue("--theme-offset-bg").trim();
target.style.setProperty(
"--bsplus-cal-text",
textPrimary || (dark ? "#f4f4f5" : "#18181b"),
);
target.style.setProperty(
"--bsplus-cal-surface",
bgPrimary || (dark ? "#27272a" : "#ffffff"),
);
target.style.setProperty(
"--bsplus-cal-surface-muted",
dark ? "#3f3f46" : "color-mix(in srgb, var(--bsplus-cal-text) 5%, var(--bsplus-cal-surface))",
);
target.style.setProperty(
"--bsplus-cal-border",
dark ? "color-mix(in srgb, #ffffff 14%, transparent)" : "color-mix(in srgb, var(--bsplus-cal-text) 12%, transparent)",
);
}
function ensureHostStyles() {
if (document.getElementById(HOST_STYLE_ID)) return;
@@ -23,8 +142,6 @@ function teardown() {
unmount(currentApp);
currentApp = null;
}
mountRoot = null;
document.querySelector(`.${CONTROLS_CLASS}`)?.remove();
document.getElementById(HOST_STYLE_ID)?.remove();
}
@@ -42,7 +159,7 @@ export async function mountGoogleCalendarButton(): Promise<void> {
controls.className = `${CONTROLS_CLASS} bsplus-timetable-control`;
toolbar.appendChild(controls);
mountRoot = document.createElement("div");
const mountRoot = document.createElement("div");
mountRoot.className = "bsplus-calendar-sync-mount";
syncCalendarSyncTheme(mountRoot);
controls.appendChild(mountRoot);