schoolbox web extension :)

Compare changes

Choose any two refs to compare.

Changed files
+287 -161
src
entrypoints
plugins
changeLogo
popup
components
inputs
routes
utils
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 9 9 private injected = false; 10 10 public toggle: StorageState<Toggle>; 11 11 public settings!: T; 12 - public menu: string | undefined; 13 12 14 13 constructor( 15 14 public meta: {
+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
··· 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 {