Merge branch 'main' into main

This commit is contained in:
Seth Burkart
2025-06-04 16:12:05 +10:00
committed by GitHub
16 changed files with 355 additions and 52 deletions
@@ -0,0 +1,99 @@
<script lang="ts">
import localforage from 'localforage'
let value = $state<string | undefined>(undefined)
let fileInput = $state<HTMLInputElement | undefined>(undefined)
let dragging = $state(false)
let blobUrl = $state<string | undefined>(undefined)
// Setup localforage instance
const store = localforage.createInstance({
name: 'profile-picture-store',
storeName: 'profilePicture',
})
async function load() {
const blob = await store.getItem<Blob>('profile-picture')
if (blob && blob instanceof Blob) {
// Revoke old blobUrl if any
if (blobUrl) URL.revokeObjectURL(blobUrl)
blobUrl = URL.createObjectURL(blob)
value = blobUrl
} else {
value = undefined
}
}
load()
function triggerSelect() {
fileInput?.click()
}
async function handleFiles(files: FileList | null) {
const file = files?.[0]
if (!file) return
// Revoke old blob URL if it exists
if (blobUrl) {
URL.revokeObjectURL(blobUrl)
}
// Store the blob in localforage
await store.setItem('profile-picture', file)
const newBlobUrl = URL.createObjectURL(file)
value = newBlobUrl
blobUrl = newBlobUrl
}
function onFileChange() {
handleFiles(fileInput?.files || null)
}
function onDrop(event: DragEvent) {
event.preventDefault()
dragging = false
handleFiles(event.dataTransfer?.files || null)
}
async function removeImage() {
if (blobUrl) {
URL.revokeObjectURL(blobUrl)
blobUrl = undefined
}
value = undefined
await store.removeItem('profile-picture')
}
</script>
<div
class="flex relative justify-center items-center rounded-lg cursor-pointer select-none border-zinc-300 dark:border-zinc-600 bg-white/20 dark:bg-zinc-800/30"
onclick={() => value ? null : triggerSelect()}
ondragover={(e) => { e.stopPropagation(); dragging = true }}
ondragleave={() => dragging = false}
ondrop={onDrop}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
triggerSelect()
}
}}
role="button"
tabindex="0"
>
{#if value}
<img src={value} alt="Profile" class="object-cover rounded-full size-10" />
<button
class="flex justify-center items-center m-1 text-lg dark:text-white size-7"
onclick={removeImage}
>&#215;</button>
{:else}
<div class="flex gap-2 items-center px-3 py-1 text-xs rounded-lg border border-dashed transition border-zinc-300 dark:border-zinc-600 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300">
<span class="text-lg font-IconFamily">{'\ued47'}</span>
<span>Upload</span>
</div>
{/if}
<input type="file" accept="image/*" class="hidden" bind:this={fileInput} onchange={onFileChange} />
{#if dragging}
<div class="absolute inset-0 rounded-full bg-zinc-200/40 dark:bg-zinc-700/40"></div>
{/if}
</div>
@@ -0,0 +1,85 @@
import type { Plugin } from "@/plugins/core/types";
import { defineSettings, componentSetting } from "@/plugins/core/settingsHelpers";
import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
import { waitForElm } from "@/seqta/utils/waitForElm";
import styles from "./styles.css?inline";
import localforage from "localforage";
const settings = defineSettings({
picture: componentSetting({
title: "Profile Picture",
description: "Upload or remove your custom profile image",
component: ProfilePictureSetting,
}),
});
const profilePicturePlugin: Plugin<typeof settings> = {
id: "profile-picture",
name: "Custom Profile Picture",
description: "Use your own image in place of the profile icon",
version: "1.1.0",
settings: settings,
disableToggle: true,
defaultEnabled: false,
styles,
run: async (api) => {
await api.storage.loaded;
let container: Element;
try {
container = await waitForElm(".userInfosvgdiv", true, 100, 60);
} catch {
return () => {};
}
const svg = container.querySelector(".userInfosvg") as HTMLElement | null;
let img: HTMLImageElement | null = null;
let currentBlobUrl: string | undefined;
// Setup localforage instance
const store = localforage.createInstance({
name: "profile-picture-store",
storeName: "profilePicture",
});
async function updateImageFromStore() {
// Remove old image if present
if (img) {
img.remove();
img = null;
}
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = undefined;
}
const blob = await store.getItem<Blob>("profile-picture");
if (blob && blob instanceof Blob) {
currentBlobUrl = URL.createObjectURL(blob);
img = document.createElement("img");
img.className = "userInfoImg";
img.src = currentBlobUrl;
if (svg) svg.style.display = "none";
container.appendChild(img);
} else {
if (svg) svg.style.display = "";
}
}
// Initial load
await updateImageFromStore();
// Listen for storage changes (in case user updates from settings)
const interval = setInterval(updateImageFromStore, 1000);
return () => {
clearInterval(interval);
if (img) img.remove();
if (svg) svg.style.display = "";
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
};
},
};
export default profilePicturePlugin;
@@ -0,0 +1,10 @@
.userInfoImg {
width: 80%;
height: 80%;
position: absolute;
top: 10%;
left: 10%;
border-radius: 50%;
object-fit: cover;
z-index: 4;
}
+5 -3
View File
@@ -7,6 +7,7 @@ import type {
StringSetting,
ButtonSetting,
HotkeySetting,
ComponentSetting,
} from "./types";
import { createPluginAPI } from "./createAPI";
import browser from "webextension-polyfill";
@@ -299,7 +300,8 @@ export class PluginManager {
options: Array<{ value: string; label: string }>;
})
| (Omit<ButtonSetting, "type"> & { type: "button"; id: string; trigger?: () => void | Promise<void> })
| (Omit<HotkeySetting, "type"> & { type: "hotkey"; id: string });
| (Omit<HotkeySetting, "type"> & { type: "hotkey"; id: string })
| (Omit<ComponentSetting, "type"> & { type: "component"; id: string; component: any });
};
// Actual type is more complex, see original code, but this gives the gist for the JSDoc.
// Array<{ pluginId: string; name: string; description: string; beta?: boolean; settings: Record<string, ProcessedSetting>; disableToggle?: boolean; }>
@@ -309,8 +311,8 @@ export class PluginManager {
([key, setting]) => {
const settingObj = setting as any;
let result: any;
if (settingObj.type === "button") {
// For button, keep the trigger function
if (settingObj.type === "button" || settingObj.type === "component") {
// For button or component, keep the functions
result = { ...settingObj };
} else {
// For others, strip functions
+11
View File
@@ -6,6 +6,7 @@ import type {
StringSetting,
HotkeySetting,
PluginSettings,
ComponentSetting,
} from "./types";
/**
@@ -114,6 +115,16 @@ export function buttonSetting(
* excluding the `type` property (e.g., `title`, `default` hotkey string).
* @returns {HotkeySetting} A complete hotkey setting object with `type: "hotkey"`.
*/
export function componentSetting(
options: Omit<ComponentSetting, "type">,
): ComponentSetting {
return {
type: "component",
...options,
};
}
export function hotkeySetting(
options: Omit<HotkeySetting, "type">,
): HotkeySetting {
+12 -2
View File
@@ -48,13 +48,21 @@ export interface HotkeySetting {
description?: string;
}
export interface ComponentSetting {
type: "component";
title: string;
description?: string;
component: any;
}
export type PluginSetting =
| BooleanSetting
| StringSetting
| NumberSetting
| SelectSetting<string>
| ButtonSetting
| HotkeySetting;
| HotkeySetting
| ComponentSetting;
export type PluginSettings = {
[key: string]: PluginSetting;
@@ -71,7 +79,9 @@ export type SettingValue<T extends PluginSetting> = T extends BooleanSetting
? O
: T extends HotkeySetting
? string
: never;
: T extends ComponentSetting
? never
: never;
export type SettingsAPI<T extends PluginSettings> = {
[K in keyof T]: SettingValue<T[K]>;
+2
View File
@@ -7,6 +7,7 @@ import themesPlugin from "./built-in/themes";
import animatedBackgroundPlugin from "./built-in/animatedBackground";
import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
import globalSearchPlugin from "./built-in/globalSearch/src/core";
import profilePicturePlugin from "./built-in/profilePicture";
//import testPlugin from './built-in/test';
// Initialize plugin manager
@@ -19,6 +20,7 @@ pluginManager.registerPlugin(assessmentsAveragePlugin);
pluginManager.registerPlugin(notificationCollectorPlugin);
pluginManager.registerPlugin(timetablePlugin);
pluginManager.registerPlugin(globalSearchPlugin);
pluginManager.registerPlugin(profilePicturePlugin);
//pluginManager.registerPlugin(testPlugin);
export { init as Monofile } from "./monofile";
+9 -4
View File
@@ -24,6 +24,9 @@ import loading from "@/seqta/ui/Loading";
import { SendNewsPage } from "@/seqta/utils/SendNewsPage";
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
import { OpenWhatsNewPopup } from "@/seqta/utils/Whatsnew";
import {
updateTimetableTimes,
} from "@/seqta/utils/updateTimetableTimes";
// JSON content
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
@@ -241,14 +244,16 @@ async function LoadPageElements(): Promise<void> {
handleReports,
);
/* eventManager.register(
eventManager.register(
"timetableAdded",
{
elementType: "div",
className: "timetablepage",
},
handleTimetable,
) */
async () => {
await updateTimetableTimes();
},
);
eventManager.register(
"noticesAdded",
@@ -503,7 +508,7 @@ export async function ObserveMenuItemPosition() {
return;
}
if (!node?.dataset?.checked && !MenuOptionsOpen) {
if (!MenuOptionsOpen) {
const key =
MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey];
if (key) {