+34
src/entrypoints/plugins/changeLogo/Menu.svelte
+34
src/entrypoints/plugins/changeLogo/Menu.svelte
···
1
+
<script lang="ts">
2
+
import Toggle from "@/entrypoints/popup/components/inputs/Toggle.svelte";
3
+
import type { Settings } from ".";
4
+
import { logos } from ".";
5
+
6
+
let { settings }: { settings: Settings } = $props();
7
+
</script>
8
+
9
+
<div class="grid grid-cols-3 gap-4">
10
+
{#await logos then logos}
11
+
{#each Object.entries(logos) as [id, logo] (id)}
12
+
<button
13
+
onclick={() => settings.logo.set({ id: id as keyof typeof logos })}
14
+
class:highlight={settings.logo.state.id === id}
15
+
class="flex flex-col rounded-lg border border-(--ctp-accent) p-2">
16
+
<span>{logo.name}</span>
17
+
<div class="flex h-full w-full items-center justify-center">
18
+
<img src={logo.url} alt="Logo" class="mt-2 w-16" />
19
+
</div>
20
+
</button>
21
+
{/each}
22
+
{/await}
23
+
</div>
24
+
25
+
<div class="mt-2">
26
+
<Toggle
27
+
text="Set as tab favicon"
28
+
update={(toggle) => {
29
+
settings.setAsFavicon.set({ toggle });
30
+
}}
31
+
checked={settings.setAsFavicon.state.toggle}
32
+
size="small"
33
+
id="setAsFavicon" />
34
+
</div>
+171
src/entrypoints/plugins/changeLogo/index.ts
+171
src/entrypoints/plugins/changeLogo/index.ts
···
1
+
import { browser } from "#imports";
2
+
import { hasChanged, injectInlineStyles, uninjectInlineStyles } from "@/utils";
3
+
import { logger } from "@/utils/logger";
4
+
import { Plugin } from "@/utils/plugin";
5
+
import type { Toggle } from "@/utils/storage";
6
+
import { globalSettings } from "@/utils/storage";
7
+
import type { StorageState } from "@/utils/storage/state.svelte";
8
+
import { flavors } from "@catppuccin/palette";
9
+
import type { Unwatch } from "wxt/utils/storage";
10
+
import schoolbox from "/schoolbox.svg?raw";
11
+
12
+
const ID = "changeLogo";
13
+
const PLUGIN_ID = `plugin-${ID}`;
14
+
15
+
let originalFavicon: string | null = null;
16
+
let unwatch: Unwatch | null = null;
17
+
18
+
export const logos = buildLogos({
19
+
schooltape: {
20
+
name: "Schooltape",
21
+
url: "schooltape.svg",
22
+
},
23
+
"schooltape-rainbow": {
24
+
name: "ST Rainbow",
25
+
url: "schooltape-ctp.svg",
26
+
},
27
+
"schooltape-legacy": {
28
+
name: "ST Legacy",
29
+
url: "https://schooltape.github.io/schooltape-legacy.svg",
30
+
},
31
+
catppuccin: {
32
+
name: "Catppuccin",
33
+
url: "https://raw.githubusercontent.com/catppuccin/catppuccin/main/assets/logos/exports/1544x1544_circle.png",
34
+
},
35
+
schoolbox: {
36
+
name: "Schoolbox",
37
+
raw: schoolbox,
38
+
},
39
+
});
40
+
41
+
export type Settings = {
42
+
setAsFavicon: StorageState<Toggle>;
43
+
logo: StorageState<{ id: keyof Awaited<typeof logos> }>;
44
+
};
45
+
46
+
interface LogoInfo {
47
+
name: string;
48
+
url: string;
49
+
}
50
+
type ImageSource = { name: string; url: string; raw?: never } | { name: string; url?: never; raw: string };
51
+
52
+
export default new Plugin<Settings>(
53
+
{
54
+
id: ID,
55
+
name: "Change Logo",
56
+
description: "Changes the Schoolbox logo to a logo of your choice.",
57
+
},
58
+
true,
59
+
{
60
+
setAsFavicon: { toggle: false },
61
+
logo: { id: "schooltape-rainbow" },
62
+
},
63
+
async (settings) => {
64
+
await inject(settings);
65
+
66
+
// add watcher to reload logo
67
+
unwatch = globalSettings.watch((newValue, oldValue) => {
68
+
if (hasChanged(newValue, oldValue, ["themeFlavour", "themeAccent"])) {
69
+
uninject();
70
+
inject(settings);
71
+
}
72
+
});
73
+
},
74
+
() => {
75
+
uninject();
76
+
77
+
// remove watcher
78
+
if (unwatch) {
79
+
unwatch();
80
+
unwatch = null;
81
+
}
82
+
},
83
+
[".logo"],
84
+
);
85
+
86
+
async function inject(settings: Settings) {
87
+
const resolvedLogos = await logos;
88
+
const logoId = (await settings.logo.get()).id;
89
+
90
+
injectLogo(resolvedLogos[logoId]);
91
+
92
+
if ((await settings.setAsFavicon.get()).toggle) {
93
+
injectFavicon(resolvedLogos[logoId]);
94
+
}
95
+
}
96
+
97
+
function uninject() {
98
+
uninjectLogo();
99
+
uninjectFavicon();
100
+
}
101
+
102
+
function injectLogo(logo: LogoInfo): void {
103
+
logger.info(`injecting logo: ${logo.name}`);
104
+
105
+
injectInlineStyles(
106
+
`a.logo > img { content: url("${logo.url}"); max-width: 30%; width: 100px; }`,
107
+
`${PLUGIN_ID}-logo`,
108
+
);
109
+
}
110
+
111
+
function uninjectLogo() {
112
+
logger.info("uninjecting logo...");
113
+
114
+
uninjectInlineStyles(`${PLUGIN_ID}-logo`);
115
+
}
116
+
117
+
function injectFavicon(logo: LogoInfo) {
118
+
logger.info(`injecting favicon: ${logo.name}`);
119
+
120
+
let favicon = document.querySelector("link[rel~='icon']") as HTMLLinkElement | null;
121
+
if (!favicon) {
122
+
favicon = document.createElement("link") as HTMLLinkElement;
123
+
favicon.rel = "icon";
124
+
document.head.appendChild(favicon);
125
+
}
126
+
127
+
originalFavicon = favicon?.href;
128
+
favicon.href = logo.url;
129
+
}
130
+
131
+
function uninjectFavicon() {
132
+
logger.info("uninjecting favicon...");
133
+
134
+
const favicon = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
135
+
if (favicon && originalFavicon) {
136
+
favicon.href = originalFavicon;
137
+
originalFavicon = null;
138
+
}
139
+
}
140
+
141
+
async function buildLogos<T extends Record<string, ImageSource>>(logos: T): Promise<Record<keyof T, LogoInfo>> {
142
+
const output: Record<keyof T, LogoInfo> = {} as Record<keyof T, LogoInfo>;
143
+
144
+
for (const [key, value] of Object.entries(logos) as [keyof T, ImageSource][]) {
145
+
let url;
146
+
147
+
if (value.url) {
148
+
if (value.url.startsWith("http")) {
149
+
url = value.url;
150
+
} else {
151
+
// @ts-expect-error unlisted CSS not a PublicPath
152
+
url = browser.runtime.getURL(value.url);
153
+
}
154
+
} else if (value.raw) {
155
+
const settings = await globalSettings.get();
156
+
const flavour = settings.themeFlavour;
157
+
const accent = settings.themeAccent;
158
+
const accentHex = flavors[flavour].colors[accent].hex;
159
+
url = `data:image/svg+xml;utf8,${encodeURIComponent(value.raw.replaceAll("currentColor", accentHex))}`;
160
+
}
161
+
162
+
if (!url) throw new Error("error getting URL for logo");
163
+
164
+
output[key] = {
165
+
name: value.name,
166
+
url,
167
+
};
168
+
}
169
+
170
+
return output;
171
+
}
+11
-1
src/entrypoints/plugins.content.ts
+11
-1
src/entrypoints/plugins.content.ts
···
1
1
import { defineContentScript } from "#imports";
2
2
import { EXCLUDE_MATCHES } from "@/utils/constants";
3
+
import changeLogo from "./plugins/changeLogo";
3
4
import homepageSwitcher from "./plugins/homepageSwitcher";
4
5
import modernIcons from "./plugins/modernIcons";
5
6
import progressBar from "./plugins/progressBar";
···
8
9
import subheader from "./plugins/subheader";
9
10
import tabTitle from "./plugins/tabTitle";
10
11
11
-
export const plugins = [subheader, scrollSegments, scrollPeriod, progressBar, modernIcons, tabTitle, homepageSwitcher];
12
+
export const plugins = [
13
+
subheader,
14
+
scrollSegments,
15
+
scrollPeriod,
16
+
progressBar,
17
+
modernIcons,
18
+
tabTitle,
19
+
changeLogo,
20
+
homepageSwitcher,
21
+
];
12
22
13
23
export type PluginInstance = (typeof plugins)[number];
14
24
+2
-2
src/entrypoints/popup/components/inputs/Toggle.svelte
+2
-2
src/entrypoints/popup/components/inputs/Toggle.svelte
···
29
29
</label>
30
30
31
31
<div
32
-
class="flex items-center justify-between text-ctp-overlay1 transition-colors duration-500 ease-in-out group-hover:text-ctp-subtext0">
32
+
class="flex items-center justify-between gap-2 text-ctp-overlay1 transition-colors duration-500 ease-in-out group-hover:text-ctp-subtext0">
33
33
<div>{description}</div>
34
-
<div>{@render children?.()}</div>
34
+
{@render children?.()}
35
35
</div>
+3
-55
src/entrypoints/popup/routes/Themes.svelte
+3
-55
src/entrypoints/popup/routes/Themes.svelte
···
1
1
<script lang="ts">
2
-
import { browser } from "#imports";
3
-
import type { LogoId } from "@/utils/storage";
4
2
import { globalSettings } from "@/utils/storage";
5
-
import { LOGO_INFO } from "@/utils/constants";
6
-
import { Palette } from "@lucide/svelte";
3
+
import type { Accent, Flavour } from "@/utils/storage";
7
4
8
5
import Title from "../components/Title.svelte";
9
-
import Modal from "../components/Modal.svelte";
10
-
import Button from "../components/inputs/Button.svelte";
11
-
import Toggle from "../components/inputs/Toggle.svelte";
12
6
13
-
const flavours = ["latte", "frappe", "macchiato", "mocha"];
7
+
const flavours: Flavour[] = ["latte", "frappe", "macchiato", "mocha"];
14
8
const accents = [
15
9
"bg-ctp-rosewater",
16
10
"bg-ctp-flamingo",
···
28
22
"bg-ctp-lavender",
29
23
];
30
24
31
-
const logos = LOGO_INFO;
32
-
let showModal = $state(false);
33
-
34
25
function cleanAccent(accent: string) {
35
26
return accent.replace("bg-ctp-", "");
36
27
}
37
28
</script>
38
29
39
-
<Modal bind:showModal>
40
-
{#snippet header()}
41
-
<h2 class="mb-4 text-xl">Choose an icon</h2>
42
-
{/snippet}
43
-
44
-
<div class="grid grid-cols-3 gap-4">
45
-
{#each Object.entries(logos) as [logoId, logo] (logoId)}
46
-
<button
47
-
onclick={() => {
48
-
globalSettings.update({ themeLogo: logoId as LogoId });
49
-
}}
50
-
class:highlight={globalSettings.state.themeLogo === logoId}
51
-
class="flex flex-col rounded-lg border border-(--ctp-accent) p-2">
52
-
<span>{logo.name}</span>
53
-
{#if logo.disable !== true}
54
-
<div class="flex h-full w-full items-center justify-center">
55
-
{#if logo.adaptive}
56
-
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
57
-
<span class="logo-picker" style="--icon: url({browser.runtime.getURL(logo.url as any)})"></span>
58
-
{:else}
59
-
<img src={logo.url} alt="Logo" class="mt-2 w-16" />
60
-
{/if}
61
-
</div>
62
-
{/if}
63
-
</button>
64
-
{/each}
65
-
</div>
66
-
67
-
<div class="mt-4">
68
-
<Toggle
69
-
update={(toggled) => {
70
-
globalSettings.update({ themeLogoAsFavicon: toggled });
71
-
}}
72
-
checked={globalSettings.state.themeLogoAsFavicon}
73
-
id="setAsFavicon"
74
-
size="small"
75
-
text="Set icon as tab favicon" />
76
-
</div>
77
-
</Modal>
78
-
79
30
<div id="card">
80
31
<Title
81
32
title="Themes"
···
105
56
aria-label={cleanAccent(accent)}
106
57
title={cleanAccent(accent)}
107
58
onclick={() => {
108
-
globalSettings.update({ themeAccent: cleanAccent(accent) });
59
+
globalSettings.update({ themeAccent: cleanAccent(accent) as Accent });
109
60
}}></button>
110
61
{/each}
111
62
</div>
112
-
113
-
<Button title="Choose icon" id="choose-icon" onclick={() => (showModal = true)}
114
-
><Palette size={22} /> Choose an icon</Button>
115
63
</div>
+4
-8
src/entrypoints/start.content.ts
+4
-8
src/entrypoints/start.content.ts
···
2
2
import {
3
3
hasChanged,
4
4
injectCatppuccin,
5
-
injectLogo,
6
5
injectStylesheet,
7
6
injectUserSnippet,
8
7
onSchoolboxPage,
···
11
10
uninjectStylesheet,
12
11
uninjectUserSnippet,
13
12
} from "@/utils";
14
-
import { EXCLUDE_MATCHES, LOGO_INFO } from "@/utils/constants";
15
-
import type { LogoId, Settings } from "@/utils/storage";
13
+
import { EXCLUDE_MATCHES } from "@/utils/constants";
14
+
import type { SettingsV2 } from "@/utils/storage";
16
15
import { globalSettings } from "@/utils/storage";
17
16
import type { WatchCallback } from "wxt/utils/storage";
18
17
import cssUrl from "./catppuccin.css?url";
···
26
25
// if not on Schoolbox page
27
26
if (!(await onSchoolboxPage())) return;
28
27
29
-
const updateThemes: WatchCallback<Settings> = async (newValue, oldValue) => {
28
+
const updateThemes: WatchCallback<SettingsV2> = async (newValue, oldValue) => {
30
29
// if global or themes was changed
31
30
if (hasChanged(newValue, oldValue, ["global", "themes", "themeFlavour", "themeAccent"])) {
32
31
if (newValue.global && newValue.themes) {
···
39
38
}
40
39
};
41
40
42
-
const updateUserSnippets: WatchCallback<Settings> = async (newValue, oldValue) => {
41
+
const updateUserSnippets: WatchCallback<SettingsV2> = async (newValue, oldValue) => {
43
42
// if global or userSnippets were changed
44
43
if (hasChanged(newValue, oldValue, ["global", "userSnippets"])) {
45
44
// uninject removed snippets
···
79
78
injectThemes();
80
79
injectCatppuccin();
81
80
}
82
-
83
-
// inject logo
84
-
injectLogo(LOGO_INFO[settings.themeLogo as LogoId], settings.themeLogoAsFavicon);
85
81
86
82
// inject user snippets
87
83
if (settings.snippets) {
-30
src/utils/constants.ts
-30
src/utils/constants.ts
···
1
-
import type { LogoId, LogoInfo } from "./storage";
2
-
3
1
export const EXCLUDE_MATCHES: string[] = ["*://*/learning/quiz/*"];
4
-
export const LOGO_INFO: Record<LogoId, LogoInfo> = {
5
-
default: {
6
-
name: "Default",
7
-
url: "default",
8
-
disable: true,
9
-
},
10
-
catppuccin: {
11
-
name: "Catppuccin",
12
-
url: "https://raw.githubusercontent.com/catppuccin/catppuccin/main/assets/logos/exports/1544x1544_circle.png",
13
-
},
14
-
schoolbox: {
15
-
name: "Schoolbox",
16
-
url: "schoolbox.svg",
17
-
adaptive: true,
18
-
},
19
-
schooltape: {
20
-
name: "Schooltape",
21
-
url: "schooltape.svg",
22
-
},
23
-
"schooltape-rainbow": {
24
-
name: "ST Rainbow",
25
-
url: "schooltape-ctp.svg",
26
-
},
27
-
"schooltape-legacy": {
28
-
name: "ST Legacy",
29
-
url: "https://schooltape.github.io/schooltape-legacy.svg",
30
-
},
31
-
};
+2
-47
src/utils/index.ts
+2
-47
src/utils/index.ts
···
1
1
import { browser } from "#imports";
2
2
import { flavorEntries } from "@catppuccin/palette";
3
3
import { logger } from "./logger";
4
-
import type { BackgroundMessage, LogoInfo } from "./storage";
4
+
import type { BackgroundMessage } from "./storage";
5
5
import { globalSettings, schoolboxUrls } from "./storage";
6
6
7
7
export const dataAttr = (id: string) => `[data-schooltape="${id}"]`;
···
20
20
const style = document.createElement("style");
21
21
style.textContent = styleText;
22
22
setDataAttr(style, `inline-${id}`);
23
-
document.head.append(style);
24
-
// logger.info(`injected styles with id ${id}`);
23
+
document.head.appendChild(style);
25
24
}
26
25
27
26
export function uninjectInlineStyles(id: string) {
···
52
51
53
52
export function uninjectCatppuccin() {
54
53
uninjectInlineStyles("catppuccin");
55
-
}
56
-
57
-
export function injectLogo(logo: LogoInfo, setAsFavicon: boolean) {
58
-
let url = logo.url;
59
-
if (!url.startsWith("http")) {
60
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
61
-
url = browser.runtime.getURL(url as any);
62
-
}
63
-
logger.info(`injecting logo: ${logo.name}`);
64
-
if (logo.disable) {
65
-
return;
66
-
}
67
-
const style = document.createElement("style");
68
-
style.classList.add("schooltape");
69
-
if (logo.adaptive) {
70
-
style.textContent = `a.logo > img { display: none !important; } a.logo { display: flex; align-items: center; justify-content: center; }`;
71
-
const span = document.createElement("span");
72
-
span.style.mask = `url("${url}") no-repeat center`;
73
-
span.style.maskSize = "100% 100%";
74
-
span.style.backgroundColor = "hsl(var(--ctp-accent))";
75
-
span.style.width = "100%";
76
-
span.style.height = "60px";
77
-
span.style.display = "block";
78
-
window.addEventListener("load", () => {
79
-
document.querySelectorAll("a.logo").forEach((logo) => {
80
-
const clonedSpan = span.cloneNode(true);
81
-
logo.append(clonedSpan);
82
-
});
83
-
});
84
-
} else {
85
-
style.textContent = `a.logo > img { content: url("${url}"); max-width: 30%; width: 100px; }`;
86
-
}
87
-
document.head.appendChild(style);
88
-
89
-
// inject favicon
90
-
if (setAsFavicon) {
91
-
let favicon = document.querySelector("link[rel~='icon']") as HTMLLinkElement | null;
92
-
if (!favicon) {
93
-
favicon = document.createElement("link") as HTMLLinkElement;
94
-
favicon.rel = "icon";
95
-
document.head.appendChild(favicon);
96
-
}
97
-
favicon.href = url;
98
-
}
99
54
}
100
55
101
56
export function injectStylesheet(url: string, id: string) {
-1
src/utils/plugin.ts
-1
src/utils/plugin.ts
+27
-4
src/utils/storage/global.ts
+27
-4
src/utils/storage/global.ts
···
1
1
import { storage } from "#imports";
2
+
import type { Settings as LogoSettings } from "@/entrypoints/plugins/changeLogo";
2
3
import { StorageState } from "./state.svelte";
3
4
import type * as Types from "./types";
4
5
5
-
export const globalSettings = new StorageState<Types.Settings>(
6
-
storage.defineItem<Types.Settings>("local:globalSettings", {
6
+
export const globalSettings = new StorageState(
7
+
storage.defineItem<Types.SettingsV2>("local:globalSettings", {
8
+
version: 2,
7
9
fallback: {
8
10
global: true,
9
11
plugins: true,
···
12
14
13
15
themeFlavour: "mocha",
14
16
themeAccent: "mauve",
15
-
themeLogo: "schooltape-rainbow",
16
-
themeLogoAsFavicon: false,
17
17
18
18
userSnippets: {},
19
+
},
20
+
migrations: {
21
+
2: async (settings: Types.SettingsV1) => {
22
+
const { themeLogo, themeLogoAsFavicon, ...rest } = settings;
23
+
24
+
// dynamic import to avoid TDZ error
25
+
const { plugins } = await import("@/entrypoints/plugins.content");
26
+
const changeLogo = plugins.find((plugin) => plugin.meta.id === "changeLogo");
27
+
28
+
if (changeLogo) {
29
+
const s = changeLogo.settings as LogoSettings;
30
+
if (themeLogo !== "default") {
31
+
// update logo
32
+
s.logo.set({ id: themeLogo });
33
+
} else {
34
+
// disable changeLogo
35
+
changeLogo.toggle.set({ toggle: false });
36
+
}
37
+
s.setAsFavicon.set({ toggle: themeLogoAsFavicon });
38
+
}
39
+
40
+
return rest;
41
+
},
19
42
},
20
43
}),
21
44
);
+33
-13
src/utils/storage/types.ts
+33
-13
src/utils/storage/types.ts
···
5
5
| { type: "updateTabUrl"; url: string };
6
6
7
7
// global
8
-
export interface Settings {
8
+
export interface SettingsV1 {
9
9
global: boolean;
10
10
plugins: boolean;
11
11
themes: boolean;
12
12
snippets: boolean;
13
13
14
-
themeFlavour: string;
15
-
themeAccent: string;
16
-
themeLogo: LogoId;
14
+
themeFlavour: Flavour;
15
+
themeAccent: Accent;
16
+
themeLogo: "default" | "schooltape" | "schooltape-rainbow" | "schooltape-legacy" | "catppuccin" | "schoolbox";
17
17
themeLogoAsFavicon: boolean;
18
18
19
19
userSnippets: Record<string, UserSnippet>;
20
20
}
21
21
22
+
export interface SettingsV2 {
23
+
global: boolean;
24
+
plugins: boolean;
25
+
themes: boolean;
26
+
snippets: boolean;
27
+
28
+
themeFlavour: Flavour;
29
+
themeAccent: Accent;
30
+
31
+
userSnippets: Record<string, UserSnippet>;
32
+
}
33
+
34
+
export type Flavour = "latte" | "frappe" | "macchiato" | "mocha";
35
+
export type Accent =
36
+
| "rosewater"
37
+
| "flamingo"
38
+
| "pink"
39
+
| "mauve"
40
+
| "red"
41
+
| "maroon"
42
+
| "peach"
43
+
| "yellow"
44
+
| "green"
45
+
| "teal"
46
+
| "sky"
47
+
| "sapphire"
48
+
| "blue"
49
+
| "lavender";
50
+
22
51
export interface UpdatedBadges {
23
52
icon: boolean;
24
53
changelog: boolean;
···
30
59
31
60
export interface SchoolboxUrls {
32
61
urls: string[];
33
-
}
34
-
35
-
export type LogoId = "default" | "catppuccin" | "schoolbox" | "schooltape" | "schooltape-rainbow" | "schooltape-legacy";
36
-
37
-
export interface LogoInfo {
38
-
name: string;
39
-
url: string;
40
-
disable?: boolean; // whether the icon should be injected or not
41
-
adaptive?: boolean; // whether the icon should follow the accent colour
42
62
}
43
63
44
64
export interface UserSnippet {