mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
feat: adaptive themeing
This commit is contained in:
@@ -306,6 +306,8 @@ function getDefaultValues(): SettingsState {
|
|||||||
lettergrade: false,
|
lettergrade: false,
|
||||||
newsSource: "australia",
|
newsSource: "australia",
|
||||||
iconOnlySidebar: false,
|
iconOnlySidebar: false,
|
||||||
|
adaptiveThemeColour: false,
|
||||||
|
adaptiveThemeGradient: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ select {
|
|||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
}
|
}
|
||||||
#container {
|
#container {
|
||||||
transition: 200ms;
|
|
||||||
background: var(--auto-background) !important;
|
background: var(--auto-background) !important;
|
||||||
}
|
}
|
||||||
:root * {
|
:root * {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
let percentage = $derived(((state - min) / (max - min)) * 100);
|
let percentage = $derived(((state - min) / (max - min)) * 100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative mx-auto w-full max-w-lg">
|
<div class="relative w-full min-w-0">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
|
|||||||
@@ -225,6 +225,35 @@
|
|||||||
{@render Setting(option)}
|
{@render Setting(option)}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Adaptive Theme Colour</h2>
|
||||||
|
<p class="text-xs">Change the theme colour based on the current class (e.g. when viewing a course or assessments page).</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
state={$settingsState.adaptiveThemeColour ?? false}
|
||||||
|
onChange={(isOn: boolean) => settingsState.adaptiveThemeColour = isOn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if $settingsState.adaptiveThemeColour}
|
||||||
|
<div class="flex justify-between items-center px-4 py-3 pl-6 border-t border-zinc-100 dark:border-zinc-700/50">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Soft Gradient</h2>
|
||||||
|
<p class="text-xs">Use a soft gradient instead of a solid colour when viewing a class.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Switch
|
||||||
|
state={$settingsState.adaptiveThemeGradient ?? false}
|
||||||
|
onChange={(isOn: boolean) => settingsState.adaptiveThemeGradient = isOn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#each pluginSettings as plugin}
|
{#each pluginSettings as plugin}
|
||||||
<div class="border-none">
|
<div class="border-none">
|
||||||
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
|
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40 {!(plugin as any).disableToggle && Object.keys(plugin.settings).length === 0 ? 'hidden' : ''}">
|
||||||
@@ -280,6 +309,7 @@
|
|||||||
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||||
/>
|
/>
|
||||||
{:else if setting.type === 'number'}
|
{:else if setting.type === 'number'}
|
||||||
|
<div class="w-28 shrink-0">
|
||||||
<Slider
|
<Slider
|
||||||
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
state={pluginSettingsValues[plugin.pluginId]?.[key] ?? setting.default}
|
||||||
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
onChange={(value) => updatePluginSetting(plugin.pluginId, key, value)}
|
||||||
@@ -287,6 +317,7 @@
|
|||||||
max={setting.max}
|
max={setting.max}
|
||||||
step={setting.step}
|
step={setting.step}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{:else if setting.type === 'string'}
|
{:else if setting.type === 'string'}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -620,7 +620,11 @@ export function init() {
|
|||||||
new StorageChangeHandler();
|
new StorageChangeHandler();
|
||||||
new MessageHandler();
|
new MessageHandler();
|
||||||
|
|
||||||
updateAllColors();
|
void updateAllColors();
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", () => {
|
||||||
|
if (settingsState.adaptiveThemeColour) void updateAllColors();
|
||||||
|
});
|
||||||
loading();
|
loading();
|
||||||
InjectCustomIcons();
|
InjectCustomIcons();
|
||||||
HideMenuItems();
|
HideMenuItems();
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import Color from "color";
|
||||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||||
import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
||||||
import ColorLuminance from "./ColorLuminance";
|
import ColorLuminance from "./ColorLuminance";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||||
|
|
||||||
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
||||||
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
||||||
@@ -13,13 +15,7 @@ const setCSSVar = (varName: any, value: any) =>
|
|||||||
const applyProperties = (props: any) =>
|
const applyProperties = (props: any) =>
|
||||||
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
|
Object.entries(props).forEach(([key, value]) => setCSSVar(key, value));
|
||||||
|
|
||||||
export function updateAllColors() {
|
function applyColorsWith(selectedColor: string) {
|
||||||
// Determine the color to use
|
|
||||||
const selectedColor =
|
|
||||||
settingsState.selectedColor !== ""
|
|
||||||
? settingsState.selectedColor
|
|
||||||
: "#007bff";
|
|
||||||
|
|
||||||
if (settingsState.transparencyEffects) {
|
if (settingsState.transparencyEffects) {
|
||||||
document.documentElement.classList.add("transparencyEffects");
|
document.documentElement.classList.add("transparencyEffects");
|
||||||
}
|
}
|
||||||
@@ -28,7 +24,7 @@ export function updateAllColors() {
|
|||||||
const commonProps = {
|
const commonProps = {
|
||||||
"--better-sub": "#161616",
|
"--better-sub": "#161616",
|
||||||
"--better-alert-highlight": "#c61851",
|
"--better-alert-highlight": "#c61851",
|
||||||
"--better-main": settingsState.selectedColor,
|
"--better-main": selectedColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mode-based properties, applied if storedSetting is provided
|
// Mode-based properties, applied if storedSetting is provided
|
||||||
@@ -79,3 +75,29 @@ export function updateAllColors() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSoftGradient(hex: string): string {
|
||||||
|
const base = Color(hex);
|
||||||
|
const analogous = base.rotate(30).lighten(0.25).saturate(0.15);
|
||||||
|
const mid = base.mix(analogous, 0.5).hex();
|
||||||
|
return `linear-gradient(135deg, ${hex} 0%, ${mid} 50%, ${analogous.hex()} 100%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAllColors() {
|
||||||
|
let effectiveColor =
|
||||||
|
settingsState.selectedColor !== ""
|
||||||
|
? settingsState.selectedColor
|
||||||
|
: "#007bff";
|
||||||
|
|
||||||
|
if (settingsState.adaptiveThemeColour) {
|
||||||
|
const adaptiveColor = await getAdaptiveColour();
|
||||||
|
if (adaptiveColor) {
|
||||||
|
effectiveColor =
|
||||||
|
settingsState.adaptiveThemeGradient
|
||||||
|
? toSoftGradient(adaptiveColor)
|
||||||
|
: adaptiveColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyColorsWith(effectiveColor);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the current page from window.location.hash.
|
||||||
|
* Returns { programme, metaclass } for /courses/SEMESTER/X:Y or /assessments/SEMESTER/X:Y, or null.
|
||||||
|
* e.g. #?page=/courses/2023S/4804:11066 or #?page=/assessments/2023S/4621:10772
|
||||||
|
*/
|
||||||
|
function parsePageContext(): { programme: number; metaclass: number } | null {
|
||||||
|
const hash = window.location.hash || "";
|
||||||
|
const match = hash.match(/[?&]page=\/(courses|assessments)\/[^/]+\/(\d+):(\d+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
const programme = parseInt(match[2], 10);
|
||||||
|
const metaclass = parseInt(match[3], 10);
|
||||||
|
if (isNaN(programme) || isNaN(metaclass)) return null;
|
||||||
|
return { programme, metaclass };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches subjects and finds the subject matching programme:metaclass.
|
||||||
|
*/
|
||||||
|
async function getSubjectCode(
|
||||||
|
programme: number,
|
||||||
|
metaclass: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${location.origin}/seqta/student/load/subjects?`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const payload = data?.payload;
|
||||||
|
if (!Array.isArray(payload)) return null;
|
||||||
|
|
||||||
|
for (const semester of payload) {
|
||||||
|
const subjects = semester?.subjects;
|
||||||
|
if (!Array.isArray(subjects)) continue;
|
||||||
|
const subject = subjects.find(
|
||||||
|
(s: any) =>
|
||||||
|
s &&
|
||||||
|
Number(s.programme) === programme &&
|
||||||
|
Number(s.metaclass) === metaclass
|
||||||
|
);
|
||||||
|
if (subject?.code) return subject.code;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[BetterSEQTA+] Adaptive theme: failed to load subjects:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches user prefs and returns the colour for the given subject code.
|
||||||
|
*/
|
||||||
|
async function getSubjectColour(
|
||||||
|
subjectCode: string,
|
||||||
|
userId: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${location.origin}/seqta/student/load/prefs?`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
request: "userPrefs",
|
||||||
|
asArray: true,
|
||||||
|
user: userId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const payload = data?.payload;
|
||||||
|
if (!Array.isArray(payload)) return null;
|
||||||
|
|
||||||
|
const pref = payload.find(
|
||||||
|
(p: { name: string; value: string }) =>
|
||||||
|
p.name === `timetable.subject.colour.${subjectCode}`
|
||||||
|
);
|
||||||
|
return pref?.value ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[BetterSEQTA+] Adaptive theme: failed to load prefs:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the adaptive theme colour for the current page context, or null.
|
||||||
|
* When viewing a course or assessments page, returns the subject's assigned colour.
|
||||||
|
*/
|
||||||
|
export async function getAdaptiveColour(): Promise<string | null> {
|
||||||
|
const context = parsePageContext();
|
||||||
|
if (!context) return null;
|
||||||
|
|
||||||
|
const subjectCode = await getSubjectCode(context.programme, context.metaclass);
|
||||||
|
if (!subjectCode) return null;
|
||||||
|
|
||||||
|
let userId: number;
|
||||||
|
try {
|
||||||
|
const userInfo = await getUserInfo();
|
||||||
|
userId = userInfo?.id;
|
||||||
|
if (typeof userId !== "number") return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colour = await getSubjectColour(subjectCode, userId);
|
||||||
|
if (!colour || typeof colour !== "string") return null;
|
||||||
|
|
||||||
|
// Basic hex validation
|
||||||
|
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(colour)) return colour;
|
||||||
|
if (/^[0-9A-Fa-f]{6}$/.test(colour)) return `#${colour}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ export class StorageChangeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers() {
|
private registerHandlers() {
|
||||||
settingsState.register("selectedColor", updateAllColors.bind(this));
|
settingsState.register("selectedColor", () => void updateAllColors());
|
||||||
|
settingsState.register("adaptiveThemeColour", () => void updateAllColors());
|
||||||
|
settingsState.register("adaptiveThemeGradient", () => void updateAllColors());
|
||||||
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
|
settingsState.register("DarkMode", this.handleDarkModeChange.bind(this));
|
||||||
settingsState.register("onoff", this.handleOnOffChange.bind(this));
|
settingsState.register("onoff", this.handleOnOffChange.bind(this));
|
||||||
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
|
settingsState.register("shortcuts", this.handleShortcutsChange.bind(this));
|
||||||
@@ -45,7 +47,7 @@ export class StorageChangeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleDarkModeChange() {
|
private handleDarkModeChange() {
|
||||||
updateAllColors();
|
void updateAllColors();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOnOffChange(newValue: boolean) {
|
private handleOnOffChange(newValue: boolean) {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface SettingsState {
|
|||||||
mockNotices?: boolean;
|
mockNotices?: boolean;
|
||||||
hideSensitiveContent?: boolean;
|
hideSensitiveContent?: boolean;
|
||||||
iconOnlySidebar?: boolean;
|
iconOnlySidebar?: boolean;
|
||||||
|
adaptiveThemeColour?: boolean;
|
||||||
|
adaptiveThemeGradient?: boolean;
|
||||||
|
|
||||||
// depreciated keys
|
// depreciated keys
|
||||||
animatedbk: boolean;
|
animatedbk: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user