feat: build themes into a centralised plugin

This commit is contained in:
SethBurkart123
2025-03-27 21:31:41 +11:00
parent 64bf1d88e8
commit f0c5b1dace
10 changed files with 877 additions and 264 deletions
-207
View File
@@ -1,207 +0,0 @@
# BetterSEQTA+ Plugin System
## Overview
The BetterSEQTA+ plugin system is designed to provide a clean, type-safe, and developer-friendly way to extend the functionality of BetterSEQTA+. While initially focused on built-in plugins, the architecture is designed to potentially support external plugins in the future.
## Core Concepts
### Plugin Structure
Each plugin is a simple object that contains metadata and a run function:
```typescript
const examplePlugin = {
id: 'example',
name: 'Example Plugin',
description: 'Does something cool',
version: '1.0.0',
settings: {
enabled: { type: 'boolean', default: true },
color: { type: 'string', default: '#ff0000' }
},
run: (api) => {
// Plugin logic here
}
};
```
### Plugin API
Plugins receive a powerful API object that provides access to:
- **Settings**: Type-safe settings management with direct property access
- **SEQTA Integration**: React component mounting and state management
- **Storage**: Persistent storage capabilities
- **Events**: Communication system
### Settings System
Settings are defined with TypeScript types for safety and accessed like regular properties:
```typescript
// In your plugin
api.settings.myOption = true;
const value = api.settings.myOption;
// Watch for changes
api.settings.onChange('myOption', (newValue) => {
console.log('Option changed:', newValue);
});
```
### SEQTA Integration
Plugins can interact with SEQTA's React components:
```typescript
// Listen for component mounting
api.seqta.onMount('.timetable-view', (element) => {
// Access the DOM element directly
console.log('Timetable mounted:', element);
// If you need React access, use getFiber
const fiber = api.seqta.getFiber('.timetable-view');
fiber.setState(prevState => ({
...prevState,
someValue: true
}));
});
// Get specific component
const fiber = api.seqta.getFiber('.timetable-cell');
const props = await fiber.getProps();
// Listen for page changes
api.seqta.onPageChange((page) => {
if (page === 'timetable') {
// Handle timetable page
}
});
```
## Implementation Status
### Phase 1: Core Infrastructure ✅
- [x] Create basic plugin type definitions
- [x] Implement plugin manager
- [x] Set up basic API structure
- [x] Create plugin loading system
### Phase 2: Settings System ✅
- [x] Design settings storage structure
- [x] Implement settings proxy system
- [x] Add settings change notifications
- [x] Create settings validation
### Phase 3: SEQTA Integration ✅
- [x] Implement component mount detection
- [x] Create ReactFiber wrapper
- [x] Add page change detection
- [x] Create component state utilities
### Phase 4: Plugin API Features ✅
- [x] Storage system
- [x] Event system
- [x] Error handling
- [ ] Plugin lifecycle hooks
### Phase 5: Migration & Testing 🚧
- [ ] Convert existing features to plugins
- [ ] Create plugin testing utilities
- [ ] Add plugin documentation
- [ ] Create example plugins
### Phase 6: Future Enhancements 📝
- [ ] Plugin dependencies system
- [ ] Plugin hot-reloading
- [ ] External plugin support
- [ ] Plugin marketplace infrastructure
## Plugin Example
```typescript
const timetablePlugin = {
id: 'timetable',
name: 'Timetable Enhancer',
description: 'Adds extra features to the timetable view',
version: '1.0.0',
settings: {
showWeekends: {
type: 'boolean',
default: false,
description: 'Show weekend days in the timetable'
},
theme: {
type: 'select',
options: ['light', 'dark', 'auto'],
default: 'auto',
description: 'Timetable theme'
}
},
run: async (api) => {
// Listen for timetable mount
api.seqta.onMount('.timetable-view', (element) => {
// Get React access since we need to modify state
const fiber = api.seqta.getFiber('.timetable-view');
// Apply settings
if (api.settings.showWeekends) {
fiber.setState(prevState => ({
...prevState,
showWeekends: true
}));
}
});
// Watch for settings changes
api.settings.onChange('theme', async (newTheme) => {
const timetable = api.seqta.getFiber('.timetable-view');
if (newTheme !== 'auto') {
await timetable.setProp('theme', newTheme);
}
});
}
};
```
## Directory Structure
```
src/
plugins/
core/
types.ts # Core type definitions
createAPI.ts # API implementation
manager.ts # Plugin manager
built-in/ # Built-in plugins
timetable/
assessments/
etc...
```
## API Type Definitions
```typescript
interface BSAPI<TSettings> {
seqta: {
onMount: (selector: string, callback: (fiber: ReactFiber) => void) => void;
getFiber: (selector: string) => ReactFiber;
getCurrentPage: () => string;
onPageChange: (callback: (page: string) => void) => void;
};
settings: TSettings & {
onChange: <K extends keyof TSettings>(
key: K,
callback: (value: TSettings[K]) => void
) => void;
};
storage: {
get: (key: string) => Promise<any>;
set: (key: string, value: any) => Promise<void>;
};
events: {
on: (event: string, callback: (...args: any[]) => void) => void;
emit: (event: string, ...args: any[]) => void;
};
}
```
@@ -1,10 +1,12 @@
<script lang="ts">
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/interface/hooks/BackgroundDataLoader';
import { setTheme } from '@/seqta/ui/themes/setTheme';
import Spinner from '../Spinner.svelte';
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import Fuse from 'fuse.js';
import { backgroundUpdates } from '@/interface/hooks/BackgroundUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
const themeManager = ThemeManager.getInstance();
type Background = { id: string; category: string; type: string; lowResUrl: string; highResUrl: string; name: string; description: string; featured?: boolean };
let { searchTerm } = $props<{ searchTerm: string }>();
@@ -170,7 +172,7 @@
function selectNoBackground() {
selectedBackground = null;
setTheme('');
themeManager.setTheme('');
}
</script>
@@ -1,16 +1,13 @@
<script lang="ts">
import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
import { onDestroy, onMount } from 'svelte'
import { OpenThemeCreator } from '@/seqta/ui/ThemeCreator'
import shareTheme from '@/seqta/ui/themes/shareTheme'
import { InstallTheme } from '@/seqta/ui/themes/downloadTheme'
import { disableTheme } from '@/seqta/ui/themes/disableTheme'
import { setTheme } from '@/seqta/ui/themes/setTheme'
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
const themeManager = ThemeManager.getInstance();
let themes = $state<ThemeList | null>(null);
let { isEditMode } = $props<{ isEditMode: boolean }>();
@@ -20,10 +17,10 @@
const handleThemeClick = async (theme: CustomTheme) => {
if (isEditMode) return;
if (theme.id === themes?.selectedTheme) {
await disableTheme();
await themeManager.disableTheme();
themes.selectedTheme = '';
} else {
await setTheme(theme.id);
await themeManager.setTheme(theme.id);
if (!themes) return;
themes.selectedTheme = theme.id;
}
@@ -31,13 +28,13 @@
const handleThemeDelete = async (themeId: string) => {
try {
await deleteTheme(themeId);
await themeManager.deleteTheme(themeId);
if (!themes) return;
themes.themes = themes.themes.filter(theme => theme.id !== themeId);
if (themeId === themes.selectedTheme) {
themes.selectedTheme = '';
await disableTheme();
await themeManager.disableTheme();
}
} catch (error) {
console.error('Error deleting theme:', error);
@@ -46,7 +43,7 @@
const handleShareTheme = async (theme: CustomTheme) => {
try {
await shareTheme(theme.id);
await themeManager.shareTheme(theme.id);
} catch (error) {
console.error('Error sharing theme:', error);
}
@@ -72,7 +69,7 @@
try {
const result = JSON.parse(event.target?.result as string);
tempTheme = result;
await InstallTheme(result);
await themeManager.installTheme(result);
await fetchThemes();
} catch (error) {
console.error('Error parsing file:', error);
@@ -84,7 +81,10 @@
}
const fetchThemes = async () => {
themes = await getAvailableThemes();
themes = {
themes: await themeManager.getAvailableThemes(),
selectedTheme: themeManager.getSelectedThemeId() || '',
}
}
onMount(async () => {
+8 -9
View File
@@ -9,16 +9,15 @@
import type { Theme } from '../types/Theme'
import browser from 'webextension-polyfill'
import ThemeModal from '../components/store/ThemeModal.svelte'
import { StoreDownloadTheme } from '@/seqta/ui/themes/downloadTheme'
import { setTheme } from '@/seqta/ui/themes/setTheme'
import Header from '../components/store/Header.svelte'
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme'
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
import Backgrounds from '../components/store/Backgrounds.svelte'
const themeManager = ThemeManager.getInstance();
// State variables
let searchTerm = $state('');
let themes = $state<Theme[]>([]);
@@ -33,8 +32,8 @@
let selectedBackground = $state<string | null>(null);
const fetchCurrentThemes = async () => {
const themes = await getAvailableThemes();
currentThemes = themes.themes.filter(theme => theme !== null).map(theme => theme.id);
const themes = await themeManager.getAvailableThemes();
currentThemes = themes.filter(theme => theme !== null).map(theme => theme.id);
};
const setDisplayTheme = (theme: Theme | null) => {
@@ -123,8 +122,8 @@
{setDisplayTheme}
onInstall={async () => {
if (displayTheme) {
await StoreDownloadTheme({themeContent: displayTheme})
setTheme(displayTheme.id);
await themeManager.downloadTheme(displayTheme);
await themeManager.setTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
@@ -132,7 +131,7 @@
onRemove={async () => {
if (displayTheme?.id) {
console.debug('deleting theme', displayTheme.id);
deleteTheme(displayTheme.id)
await themeManager.deleteTheme(displayTheme.id);
themeUpdates.triggerUpdate();
await fetchCurrentThemes();
}
+14 -14
View File
@@ -7,7 +7,6 @@
import { type LoadedCustomTheme } from '@/types/CustomThemes'
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
import { getTheme } from '@/seqta/ui/themes/getTheme'
import Divider from '@/interface/components/themeCreator/divider.svelte'
import Switch from '@/interface/components/Switch.svelte'
@@ -22,14 +21,13 @@
handleImageVariableChange,
handleCoverImageUpload
} from '../utils/themeImageHandlers';
import { ClearThemePreview, UpdateThemePreview } from '@/seqta/ui/themes/UpdateThemePreview'
import { saveTheme } from '@/seqta/ui/themes/saveTheme'
import { CloseThemeCreator } from '@/seqta/ui/ThemeCreator'
import { themeUpdates } from '../hooks/ThemeUpdates'
import { disableTheme } from '@/seqta/ui/themes/disableTheme'
import { setTheme } from '@/seqta/ui/themes/setTheme'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
const { themeID } = $props<{ themeID: string }>()
const themeManager = ThemeManager.getInstance();
let theme = $state<LoadedCustomTheme>({
id: uuidv4(),
name: '',
@@ -62,10 +60,10 @@
}
onMount(async () => {
await disableTheme();
await themeManager.disableTheme();
if (themeID) {
const tempTheme = await getTheme(themeID)
const tempTheme = await themeManager.getTheme(themeID)
if (!tempTheme) return
@@ -104,7 +102,7 @@
theme = await handleCoverImageUpload(event, theme);
}
function submitTheme() {
async function submitTheme() {
const themeClone = JSON.parse(JSON.stringify(theme));
// re-insert blobs into themeClone
@@ -114,15 +112,17 @@
}))
themeClone.coverImage = theme.coverImage
ClearThemePreview();
saveTheme(themeClone);
setTheme(themeClone.id);
themeManager.clearPreview();
await themeManager.saveTheme(themeClone);
await themeManager.setTheme(themeClone.id);
themeUpdates.triggerUpdate();
CloseThemeCreator();
}
$effect(() => {
UpdateThemePreview(theme);
if (themeLoaded) {
void themeManager.updatePreview(theme);
}
});
type SettingType = 'switch' | 'button' | 'slider' | 'colourPicker' | 'select' | 'codeEditor' | 'imageUpload' | 'conditional' | 'lightDarkToggle';
@@ -215,7 +215,7 @@
bind:value={image.variableName}
oninput={(e) => onImageVariableChange(image.id, e.currentTarget.value)}
placeholder="CSS Variable Name"
class="grow flex-3 w-full p-2 transition border-0 rounded-lg dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
class="p-2 w-full rounded-lg border-0 transition grow flex-3 dark:placeholder-zinc-300 bg-zinc-200 dark:bg-zinc-600/50 focus:bg-zinc-300/50 dark:focus:bg-zinc-600"
/>
<button onclick={() => onRemoveImage(image.id)} class="p-2 transition dark:text-white">
<span class='text-xl font-IconFamily'>{'\ued8c'}</span>
@@ -253,7 +253,7 @@
<div class='h-screen overflow-y-scroll {$settingsState.DarkMode && "dark"} no-scrollbar'>
{#if codeEditorFullscreen}
<div class="absolute inset-0 z-10000 bg-white dark:bg-zinc-900 dark:text-white">
<div class="absolute inset-0 bg-white z-10000 dark:bg-zinc-900 dark:text-white">
<div class="sticky top-0 px-2 h-screen">
<div class="flex justify-between items-center my-4">
<h2 class="text-xl font-bold">Custom CSS</h2>
+54
View File
@@ -0,0 +1,54 @@
import type { Plugin } from '../../core/types';
import { BasePlugin, BooleanSetting } from '../../core/settings';
import { ThemeManager } from './theme-manager';
// Define only the typed settings - no need for redundant interface
class ThemePluginClass extends BasePlugin {
@BooleanSetting({
default: true,
title: "Themes",
description: "Adds a theme selector to the settings page"
})
enabled!: boolean;
}
// Create an instance to extract settings
const settingsInstance = new ThemePluginClass();
const themesPlugin: Plugin<typeof settingsInstance.settings> = {
id: 'themes',
name: 'Themes',
description: 'Adds a theme selector to the settings page',
version: '1.0.0',
settings: settingsInstance.settings,
run: async (api) => {
console.debug('[ThemesPlugin] Starting plugin');
const themeManager = ThemeManager.getInstance();
if (api.settings.enabled) {
console.debug('[ThemesPlugin] Plugin enabled, initializing theme manager');
await themeManager.initialize();
}
const enabledCallback = (value: string | number | boolean) => {
console.debug('[ThemesPlugin] Enabled setting changed:', value);
if (value === true) {
console.debug('[ThemesPlugin] Plugin enabled, initializing theme manager');
void themeManager.initialize();
} else if (value === false) {
console.debug('[ThemesPlugin] Plugin disabled, cleaning up theme manager');
void themeManager.cleanup();
}
}
api.settings.onChange('enabled', enabledCallback);
return () => {
console.debug('[ThemesPlugin] Plugin cleanup');
api.settings.offChange('enabled', enabledCallback);
void themeManager.cleanup();
}
}
};
export default themesPlugin;
@@ -0,0 +1,644 @@
import localforage from 'localforage';
import type { CustomTheme, LoadedCustomTheme } from '@/types/CustomThemes';
import { settingsState } from '@/seqta/utils/listeners/SettingsState';
type ThemeContent = {
id: string;
name: string;
coverImage: string; // base64
description: string;
defaultColour: string;
CanChangeColour: boolean;
CustomCSS: string;
hideThemeName: boolean;
images: { id: string, variableName: string, data: string }[]; // data: base64
};
export class ThemeManager {
private static instance: ThemeManager;
private currentTheme: CustomTheme | null = null;
private styleElement: HTMLStyleElement | null = null;
private previewStyleElement: HTMLStyleElement | null = null;
private previousImageVariableNames: string[] = [];
private originalPreviewColor: string | null = null;
private originalPreviewTheme: boolean | null = null;
private imageUrlCache: Map<string, string> = new Map();
private constructor() {
console.debug('[ThemeManager] Initializing...');
}
public static getInstance(): ThemeManager {
if (!ThemeManager.instance) {
ThemeManager.instance = new ThemeManager();
}
return ThemeManager.instance;
}
/**
* Get the currently active theme
*/
public getCurrentTheme(): CustomTheme | null {
return this.currentTheme;
}
/**
* Get a theme by ID from storage
*/
public async getTheme(themeId: string): Promise<CustomTheme | null> {
console.debug('[ThemeManager] Getting theme:', themeId);
try {
const theme = await localforage.getItem(themeId) as CustomTheme;
return theme;
} catch (error) {
console.error('[ThemeManager] Error getting theme:', error);
return null;
}
}
/**
* Get the ID of the currently selected theme
*/
public getSelectedThemeId(): string {
return settingsState.selectedTheme;
}
/**
* Disable the current theme without deleting it
*/
public async disableTheme(): Promise<void> {
console.debug('[ThemeManager] Disabling current theme');
try {
if (!this.currentTheme) {
console.debug('[ThemeManager] No theme to disable');
return;
}
await this.removeTheme(this.currentTheme);
this.currentTheme = null;
settingsState.selectedTheme = '';
console.debug('[ThemeManager] Theme disabled successfully');
} catch (error) {
console.error('[ThemeManager] Error disabling theme:', error);
}
}
/**
* Initialize the theme system and restore previous state
*/
public async initialize(): Promise<void> {
console.debug('[ThemeManager] Starting initialization');
try {
if (settingsState.selectedTheme) {
console.debug('[ThemeManager] Found selected theme, restoring:', settingsState.selectedTheme);
await this.setTheme(settingsState.selectedTheme);
}
} catch (error) {
console.error('[ThemeManager] Error during initialization:', error);
}
}
/**
* Clean up theme system resources
*/
public async cleanup(): Promise<void> {
console.debug('[ThemeManager] Cleaning up resources');
try {
if (this.currentTheme) {
await this.removeTheme(this.currentTheme);
}
} catch (error) {
console.error('[ThemeManager] Error during cleanup:', error);
}
}
/**
* Set and apply a theme by ID
*/
public async setTheme(themeId: string): Promise<void> {
console.debug('[ThemeManager] Setting theme:', themeId);
try {
const theme = await localforage.getItem(themeId) as CustomTheme;
if (!theme) {
console.error('[ThemeManager] Theme not found:', themeId);
return;
}
// Store original settings before applying new theme
if (!settingsState.selectedTheme) {
console.debug('[ThemeManager] Storing original settings');
settingsState.originalSelectedColor = settingsState.selectedColor;
settingsState.originalDarkMode = settingsState.DarkMode;
}
// Remove current theme if exists
if (this.currentTheme) {
console.debug('[ThemeManager] Removing current theme');
await this.removeTheme(this.currentTheme);
}
// Apply new theme
await this.applyTheme(theme);
this.currentTheme = theme;
settingsState.selectedTheme = themeId;
} catch (error) {
console.error('[ThemeManager] Error setting theme:', error);
}
}
/**
* Apply theme components (CSS, images, settings)
*/
private async applyTheme(theme: CustomTheme): Promise<void> {
console.debug('[ThemeManager] Applying theme:', theme.name);
try {
// Apply custom CSS
if (theme.CustomCSS) {
console.debug('[ThemeManager] Applying custom CSS');
this.applyCustomCSS(theme.CustomCSS);
}
// Apply custom images
if (theme.CustomImages) {
console.debug('[ThemeManager] Applying custom images');
theme.CustomImages.forEach((image) => {
const imageUrl = URL.createObjectURL(image.blob);
document.documentElement.style.setProperty('--' + image.variableName, `url(${imageUrl})`);
});
}
// Apply theme settings
if (theme.forceDark !== undefined) {
console.debug('[ThemeManager] Setting dark mode:', theme.forceDark);
settingsState.DarkMode = theme.forceDark;
}
if (theme.defaultColour) {
console.debug('[ThemeManager] Setting color:', theme.defaultColour);
settingsState.selectedColor = theme.defaultColour;
}
} catch (error) {
console.error('[ThemeManager] Error applying theme:', error);
}
}
/**
* Remove theme and restore original settings
*/
private async removeTheme(theme: CustomTheme): Promise<void> {
console.debug('[ThemeManager] Removing theme:', theme.name);
try {
// Remove custom CSS
if (this.styleElement) {
console.debug('[ThemeManager] Removing custom CSS');
this.styleElement.remove();
this.styleElement = null;
}
// Remove custom images
if (theme.CustomImages) {
console.debug('[ThemeManager] Removing custom images');
theme.CustomImages.forEach((image) => {
const value = document.documentElement.style.getPropertyValue('--' + image.variableName);
if (value) {
URL.revokeObjectURL(value.slice(4, -1)); // Remove url() wrapper
}
document.documentElement.style.removeProperty('--' + image.variableName);
});
}
// Restore original settings
if (settingsState.originalSelectedColor) {
console.debug('[ThemeManager] Restoring original color:', settingsState.originalSelectedColor);
settingsState.selectedColor = settingsState.originalSelectedColor;
}
if (settingsState.originalDarkMode !== undefined) {
console.debug('[ThemeManager] Restoring original dark mode:', settingsState.originalDarkMode);
settingsState.DarkMode = settingsState.originalDarkMode;
settingsState.originalDarkMode = undefined;
}
this.currentTheme = null;
settingsState.selectedTheme = '';
} catch (error) {
console.error('[ThemeManager] Error removing theme:', error);
}
}
/**
* Apply custom CSS to the document
*/
private applyCustomCSS(css: string): void {
console.debug('[ThemeManager] Applying custom CSS');
try {
if (!this.styleElement) {
this.styleElement = document.createElement('style');
this.styleElement.id = 'custom-theme';
document.head.appendChild(this.styleElement);
}
this.styleElement.textContent = css;
} catch (error) {
console.error('[ThemeManager] Error applying custom CSS:', error);
}
}
/**
* Get list of available themes
*/
public async getAvailableThemes(): Promise<CustomTheme[]> {
console.debug('[ThemeManager] Getting available themes');
try {
const themeIds = await localforage.getItem('customThemes') as string[] | null;
if (!themeIds) {
return [];
}
const themes = await Promise.all(
themeIds.map(async (id) => {
return await localforage.getItem(id) as CustomTheme;
})
);
return themes.filter(theme => theme !== null);
} catch (error) {
console.error('[ThemeManager] Error getting available themes:', error);
return [];
}
}
/**
* Save or update a theme
*/
public async saveTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug('[ThemeManager] Saving theme:', theme.name);
try {
await localforage.setItem(theme.id, theme);
const themeIds = await localforage.getItem('customThemes') as string[] | null;
if (themeIds) {
if (!themeIds.includes(theme.id)) {
themeIds.push(theme.id);
await localforage.setItem('customThemes', themeIds);
}
} else {
await localforage.setItem('customThemes', [theme.id]);
}
} catch (error) {
console.error('[ThemeManager] Error saving theme:', error);
}
}
/**
* Delete a theme
*/
public async deleteTheme(themeId: string): Promise<void> {
console.debug('[ThemeManager] Deleting theme:', themeId);
try {
const theme = await localforage.getItem(themeId) as CustomTheme;
if (theme) {
if (this.currentTheme?.id === themeId) {
await this.removeTheme(theme);
}
await localforage.removeItem(themeId);
const themeIds = await localforage.getItem('customThemes') as string[] | null;
if (themeIds) {
const updatedThemeIds = themeIds.filter(id => id !== themeId);
await localforage.setItem('customThemes', updatedThemeIds);
}
}
} catch (error) {
console.error('[ThemeManager] Error deleting theme:', error);
}
}
/**
* Download and install a theme from the store
*/
public async downloadTheme(themeContent: { id: string; name: string; description: string; coverImage: string; }): Promise<void> {
console.debug('[ThemeManager] Downloading theme:', themeContent.name);
try {
if (!themeContent.id) return;
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`);
const themeData = await response.json() as ThemeContent;
await this.installTheme(themeData);
} catch (error) {
console.error('[ThemeManager] Error downloading theme:', error);
}
}
/**
* Install a theme from theme data
*/
public async installTheme(themeData: ThemeContent): Promise<void> {
console.debug('[ThemeManager] Installing theme:', themeData.name);
try {
const strippedCoverImage = this.stripBase64Prefix(themeData.coverImage);
const coverImageBlob = this.base64ToBlob(strippedCoverImage);
const images = themeData.images.map((image) => ({
...image,
blob: this.base64ToBlob(this.stripBase64Prefix(image.data))
}));
const theme: LoadedCustomTheme = {
...themeData,
webURL: themeData.id,
coverImage: coverImageBlob,
CustomImages: images.map((image) => ({
id: image.id,
variableName: image.variableName,
blob: image.blob
})),
allowBackgrounds: true, // Default to allowing backgrounds
isEditable: false // Downloaded themes are not editable by default
};
await this.saveTheme(theme);
} catch (error) {
console.error('[ThemeManager] Error installing theme:', error);
}
}
/**
* Share a theme by exporting it
*/
public async shareTheme(themeId: string): Promise<void> {
console.debug('[ThemeManager] Sharing theme:', themeId);
try {
const theme = await localforage.getItem(themeId) as LoadedCustomTheme;
if (!theme) {
console.error('[ThemeManager] Theme not found');
return;
}
const { CustomImages = [], coverImage, ...themeWithoutImages } = theme;
// Convert images to base64
const finalImages = await Promise.all(CustomImages.map(async (image) => ({
id: image.id,
variableName: image.variableName,
data: await this.blobToBase64(image.blob)
})));
// Convert cover image to base64
const coverImageBase64 = coverImage ? await this.blobToBase64(coverImage) : null;
// Create shareable theme data
const shareableTheme = {
...themeWithoutImages,
images: finalImages,
coverImage: coverImageBase64
};
// Save theme file
this.saveThemeFile(shareableTheme, theme.name || 'Unnamed_Theme');
} catch (error) {
console.error('[ThemeManager] Error sharing theme:', error);
}
}
/**
* Preview a theme without applying it
*/
public async previewTheme(theme: LoadedCustomTheme): Promise<void> {
console.debug('[ThemeManager] Previewing theme:', theme.name);
try {
const { CustomCSS, CustomImages, defaultColour, forceDark } = theme;
// Store original settings if not already stored
if (this.originalPreviewColor === null) {
this.originalPreviewColor = settingsState.selectedColor;
}
if (this.originalPreviewTheme === null) {
this.originalPreviewTheme = settingsState.DarkMode;
}
// Apply custom CSS
if (CustomCSS) {
this.applyPreviewCSS(CustomCSS);
}
// Apply custom images
const newImageVariableNames = CustomImages.map(image => image.variableName);
// Remove old preview images
this.previousImageVariableNames.forEach(variableName => {
if (!newImageVariableNames.includes(variableName)) {
this.removeImageFromDocument(variableName);
}
});
// Apply new images
CustomImages.forEach((image) => {
const imageUrl = URL.createObjectURL(image.blob);
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${imageUrl})`);
});
// Update previousImageVariableNames
this.previousImageVariableNames = newImageVariableNames;
// Apply theme settings
if (forceDark !== undefined) {
settingsState.DarkMode = forceDark;
}
if (defaultColour) {
settingsState.selectedColor = defaultColour;
}
} catch (error) {
console.error('[ThemeManager] Error previewing theme:', error);
}
}
/**
* Update the preview of a theme in real-time (for theme creator)
*/
public async updatePreview(theme: Partial<LoadedCustomTheme>): Promise<void> {
console.debug('[ThemeManager] Updating theme preview');
try {
// Store original settings if not already stored
if (this.originalPreviewColor === null) {
this.originalPreviewColor = settingsState.selectedColor;
}
if (this.originalPreviewTheme === null) {
this.originalPreviewTheme = settingsState.DarkMode;
}
// Apply CSS if changed
if (theme.CustomCSS !== undefined) {
this.applyPreviewCSS(theme.CustomCSS);
}
// Handle images if present
if (theme.CustomImages) {
const newImageVariableNames = theme.CustomImages.map(image => image.variableName);
// Remove old preview images that are no longer present
this.previousImageVariableNames.forEach(variableName => {
if (!newImageVariableNames.includes(variableName)) {
this.removeImageFromDocument(variableName);
// Clean up cached URL
this.imageUrlCache.delete(variableName);
}
});
// Apply or update images
theme.CustomImages.forEach((image) => {
const existingUrl = this.imageUrlCache.get(image.variableName);
if (!existingUrl) {
// Only create new URL if one doesn't exist
const imageUrl = URL.createObjectURL(image.blob);
this.imageUrlCache.set(image.variableName, imageUrl);
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${imageUrl})`);
} else {
// Reuse existing URL
document.documentElement.style.setProperty(`--${image.variableName}`, `url(${existingUrl})`);
}
});
this.previousImageVariableNames = newImageVariableNames;
}
// Update theme settings
if (theme.forceDark !== undefined) {
settingsState.DarkMode = theme.forceDark;
}
if (theme.defaultColour) {
settingsState.selectedColor = theme.defaultColour;
}
} catch (error) {
console.error('[ThemeManager] Error updating theme preview:', error);
}
}
/**
* Clear theme preview
*/
public clearPreview(): void {
console.debug('[ThemeManager] Clearing theme preview');
try {
// Remove preview images and revoke URLs
this.previousImageVariableNames.forEach(variableName => {
this.removeImageFromDocument(variableName);
});
// Clear all cached URLs
this.imageUrlCache.forEach(url => URL.revokeObjectURL(url));
this.imageUrlCache.clear();
this.previousImageVariableNames = [];
// Remove preview CSS
if (this.previewStyleElement) {
this.previewStyleElement.remove();
this.previewStyleElement = null;
}
// Restore original settings
if (this.originalPreviewColor !== null) {
settingsState.selectedColor = this.originalPreviewColor;
this.originalPreviewColor = null;
}
if (this.originalPreviewTheme !== null) {
settingsState.DarkMode = this.originalPreviewTheme;
this.originalPreviewTheme = null;
}
} catch (error) {
console.error('[ThemeManager] Error clearing preview:', error);
}
}
// Utility methods
private stripBase64Prefix(base64String: string): string {
if (!base64String) return '';
const prefixRegex = /^data:[^;]+;base64,/;
try {
return prefixRegex.test(base64String) ? base64String.replace(prefixRegex, '') : base64String;
} catch(err) {
console.error('[ThemeManager] Error stripping base64 prefix:', err);
return '';
}
}
private base64ToBlob(base64: string): Blob {
try {
const byteString = atob(base64);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: 'image/png' });
} catch(err) {
console.error('[ThemeManager] Error converting base64 to blob:', err);
return new Blob();
}
}
private async blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
const base64Data = base64String.split(',')[1];
resolve(base64Data);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
private saveThemeFile(data: object, fileName: string): void {
try {
const fileData = JSON.stringify(data, null, 2);
const blob = new Blob([fileData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.theme.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch(err) {
console.error('[ThemeManager] Error saving theme file:', err);
}
}
private removeImageFromDocument(variableName: string): void {
try {
const value = document.documentElement.style.getPropertyValue('--' + variableName);
if (value) {
const url = this.imageUrlCache.get(variableName);
if (url) {
URL.revokeObjectURL(url);
this.imageUrlCache.delete(variableName);
}
}
document.documentElement.style.removeProperty('--' + variableName);
} catch(err) {
console.error('[ThemeManager] Error removing image from document:', err);
}
}
private applyPreviewCSS(css: string): void {
console.debug('[ThemeManager] Applying preview CSS');
try {
if (!this.previewStyleElement) {
this.previewStyleElement = document.createElement('style');
this.previewStyleElement.id = 'custom-theme-preview';
document.head.appendChild(this.previewStyleElement);
}
this.previewStyleElement.textContent = css;
} catch (error) {
console.error('[ThemeManager] Error applying preview CSS:', error);
}
}
}
+2
View File
@@ -3,6 +3,7 @@ import { PluginManager } from './core/manager';
// plugins
import timetablePlugin from './built-in/timetable';
import notificationCollectorPlugin from './built-in/notificationCollector';
import themesPlugin from './built-in/themes';
// Initialize plugin manager
const pluginManager = PluginManager.getInstance();
@@ -10,6 +11,7 @@ const pluginManager = PluginManager.getInstance();
// Register built-in plugins
pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin);
pluginManager.registerPlugin(themesPlugin);
//pluginManager.registerPlugin(testPlugin);
// Legacy plugin exports
+15 -19
View File
@@ -3,20 +3,16 @@ import browser from 'webextension-polyfill'
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { MenuOptionsOpen, OpenMenuOptions } from "@/seqta/utils/Openers/OpenMenuOptions"
import { deleteTheme } from '@/seqta/ui/themes/deleteTheme';
import { getAvailableThemes } from '@/seqta/ui/themes/getAvailableThemes';
import { saveTheme } from '@/seqta/ui/themes/saveTheme';
import { UpdateThemePreview } from '@/seqta/ui/themes/UpdateThemePreview';
import { getTheme } from '@/seqta/ui/themes/getTheme';
import { setTheme } from '@/seqta/ui/themes/setTheme';
import { disableTheme } from '@/seqta/ui/themes/disableTheme';
import { CloseThemeCreator, OpenThemeCreator } from '@/seqta/ui/ThemeCreator';
import ShareTheme from '@/seqta/ui/themes/shareTheme';
import sendThemeUpdate from '@/seqta/utils/sendThemeUpdate';
import hideSensitiveContent from '@/seqta/ui/dev/hideSensitiveContent';
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager';
const themeManager = ThemeManager.getInstance();
export class MessageHandler {
constructor() {
// @ts-ignore
browser.runtime.onMessage.addListener(this.routeMessage.bind(this));
}
routeMessage(request: any, _sender: any, sendResponse: any) {
@@ -32,46 +28,46 @@ export class MessageHandler {
case 'UpdateThemePreview':
if (request?.save == true) {
const save = async () => {
await saveTheme(request.body)
await themeManager.saveTheme(request.body)
if (request.body.enableTheme) {
await setTheme(request.body.id)
await themeManager.setTheme(request.body.id)
}
sendResponse({ status: 'success' })
sendThemeUpdate()
}
save()
} else {
UpdateThemePreview(request.body);
themeManager.updatePreview(request.body);
sendResponse({ status: 'success' });
}
return true;
case 'GetTheme':
getTheme(request.body.themeID).then((theme) => {
themeManager.getTheme(request.body.themeID).then((theme) => {
sendResponse(theme);
});
return true;
case 'SetTheme':
setTheme(request.body.themeID).then(() => {
themeManager.setTheme(request.body.themeID).then(() => {
sendResponse({ status: 'success' });
});
break;
case 'DisableTheme':
disableTheme().then(() => {
themeManager.disableTheme().then(() => {
sendResponse({ status: 'success' });
});
break;
case 'DeleteTheme':
deleteTheme(request.body.themeID).then(() => {
themeManager.deleteTheme(request.body.themeID).then(() => {
sendResponse({ status: 'success' });
});
break;
case 'ListThemes':
getAvailableThemes().then((themes) => {
themeManager.getAvailableThemes().then((themes) => {
sendResponse(themes);
});
return true;
@@ -84,7 +80,7 @@ export class MessageHandler {
break;
case 'ShareTheme':
ShareTheme(request.body.themeID).then((id) => {
themeManager.shareTheme(request.body.themeID).then((id) => {
sendResponse({ status: 'success', id });
});
return true;
@@ -105,8 +101,8 @@ export class MessageHandler {
break;
default:
console.debug('Unknown request info:', request.info);
}
console.debug('Unknown request info:', request.info);
}
}
editSidebar() {
+123
View File
@@ -0,0 +1,123 @@
# BetterSEQTA+ Theme System Documentation
## Overview
The BetterSEQTA+ theme system allows users to customize their SEQTA interface with custom CSS, colors, and images. Themes are stored locally using `localforage` and can be shared, downloaded, and modified.
## Theme Storage
Themes are stored using `localforage` in two main ways:
1. A list of theme IDs is stored under the key 'customThemes'
2. Individual themes are stored using their unique ID as the key
## Theme Structure
A theme consists of the following components:
```typescript
type CustomTheme = {
id: string; // Unique identifier for the theme
name: string; // Display name
description: string; // Theme description
defaultColour: string; // Default accent color
CanChangeColour: boolean; // Whether users can change the accent color
allowBackgrounds: boolean; // Whether background customization is allowed
CustomCSS: string; // Custom CSS styles
CustomImages: CustomImage[]; // Array of custom images used in the theme
coverImage: Blob | null; // Theme preview image
isEditable: boolean; // Whether the theme can be edited
hideThemeName: boolean; // Whether to hide the theme name in UI
webURL?: string; // Optional URL for web-downloaded themes
selectedColor?: string; // Currently selected accent color
forceDark?: boolean; // Force dark mode when theme is active
}
```
## Theme Management Functions
### Core Functions
1. `setTheme(themeId)`: Activates a theme
- Removes currently active theme
- Applies new theme's CSS and images
- Updates color settings
2. `applyTheme(theme)`: Applies theme components
- Applies custom CSS
- Sets up custom images
- Handles dark mode settings
3. `removeTheme(theme)`: Cleans up theme components
- Removes custom CSS
- Cleans up image URLs
- Restores original settings
### Theme Storage Operations
1. `saveTheme(theme)`: Saves/updates a theme
- Stores theme data in localforage
- Updates theme list if new
- Triggers theme update notifications
2. `deleteTheme(themeId)`: Removes a theme
- Removes theme data
- Updates theme list
- Cleans up theme components
### Theme Sharing
1. `shareTheme(themeId)`: Exports theme for sharing
- Converts blobs to base64
- Packages theme data
- Creates downloadable JSON file
2. `downloadTheme(theme)`: Installs shared theme
- Converts base64 to blobs
- Stores theme data
- Updates theme list
## State Management
The theme system uses a `settingsState` object to track:
- Currently selected theme (`selectedTheme`)
- Original and current color settings (`originalSelectedColor`, `selectedColor`)
- Dark mode state (`DarkMode`, `originalDarkMode`)
## Known Issues and Considerations
### Image Handling
1. Images are stored as Blobs and converted to URLs for display
2. Need to properly revoke object URLs to prevent memory leaks
3. Image variable names must be unique across themes
### Color Management
1. Theme colors can override user preferences
2. Need to properly restore original colors when disabling themes
3. Color change permissions (`CanChangeColour`) may need better enforcement
### CSS Application
1. CSS is applied through a single `<style>` element with id 'custom-theme'
2. Multiple themes attempting to apply CSS simultaneously could cause conflicts
3. CSS specificity issues might affect theme application
### State Persistence
1. Theme state needs to be properly restored on page reload
2. Dark mode preferences need better synchronization with theme settings
3. Original settings should be properly preserved and restored
## Best Practices
### Theme Development
1. Use unique, descriptive variable names for custom images
2. Include proper fallbacks in custom CSS
3. Test themes in both light and dark modes
4. Provide clear documentation for custom variables and features
### Theme Management
1. Always clean up resources when disabling/removing themes
2. Implement proper error handling for theme operations
3. Validate theme data before saving/applying
4. Maintain backward compatibility for theme formats
## Future Improvements
1. Theme versioning system
2. Better conflict resolution between themes
3. Theme dependencies management
4. Theme update mechanism
5. Theme preview system
6. Better error handling and user feedback
7. Theme categories and tags
8. Theme export/import validation