mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 304ce2e128 | |||
| 0bc6beb0f1 | |||
| 68173a8b75 | |||
| 7583d0ee47 | |||
| 6c79fe3588 | |||
| c0a8a76105 | |||
| 6ad214bb09 | |||
| b4598668d4 | |||
| 01e679eab6 | |||
| f57bd107b9 | |||
| aa5d193e55 | |||
| da5bc7ab11 | |||
| b0857054eb | |||
| f721bf6609 | |||
| 2aecd63850 | |||
| f35520029f | |||
| 95994fcd3a | |||
| 999f12e958 | |||
| 260afac294 | |||
| 678a958351 | |||
| a1131cf6cd | |||
| f7d9199500 | |||
| 5178408f39 | |||
| 0b51db5434 | |||
| 9c47fa38ae | |||
| 5c4d7e1be3 | |||
| acbbac8266 | |||
| fa8f36f3d5 | |||
| 44116edca5 | |||
| 37be31859f | |||
| 10667f17b4 | |||
| 0ca0c7cf43 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"version": "3.6.4",
|
"version": "3.6.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
||||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||||
|
|||||||
+30
-3
@@ -9,6 +9,21 @@ import {
|
|||||||
runCloudSettingsPoll,
|
runCloudSettingsPoll,
|
||||||
} from "./background/cloudSettingsAutoSync";
|
} from "./background/cloudSettingsAutoSync";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-only dev-mode override of the content API base.
|
||||||
|
*
|
||||||
|
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
|
||||||
|
* automatically when the browser/service-worker process restarts. Content
|
||||||
|
* scripts re-sync this on every page load via `setDevApiBase` so the value
|
||||||
|
* survives transient service-worker terminations within the same browser
|
||||||
|
* session.
|
||||||
|
*/
|
||||||
|
const DEFAULT_API_BASE = "https://betterseqta.org";
|
||||||
|
let DEV_API_BASE: string | null = null;
|
||||||
|
function apiBase(): string {
|
||||||
|
return DEV_API_BASE ?? DEFAULT_API_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
function reloadSeqtaPages() {
|
function reloadSeqtaPages() {
|
||||||
const result = browser.tabs.query({});
|
const result = browser.tabs.query({});
|
||||||
function open(tabs: any) {
|
function open(tabs: any) {
|
||||||
@@ -29,7 +44,7 @@ type MessageSender = { (response?: unknown): void };
|
|||||||
|
|
||||||
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
|
||||||
const { token } = request;
|
const { token } = request;
|
||||||
const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
|
||||||
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
@@ -57,7 +72,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
|
|||||||
}
|
}
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
|
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(sendResponse)
|
.then(sendResponse)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -283,7 +298,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const isFavorite = action === "favorite";
|
const isFavorite = action === "favorite";
|
||||||
fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
|
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
|
||||||
method: isFavorite ? "POST" : "DELETE",
|
method: isFavorite ? "POST" : "DELETE",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
@@ -310,8 +325,19 @@ function isSeqtaOrigin(origin: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSetDevApiBase(request: any): boolean {
|
||||||
|
const url = typeof request?.url === "string" ? request.url.trim() : null;
|
||||||
|
if (url && /^https?:\/\//.test(url)) {
|
||||||
|
DEV_API_BASE = url.replace(/\/$/, "");
|
||||||
|
} else {
|
||||||
|
DEV_API_BASE = null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
||||||
reloadTabs: () => reloadSeqtaPages(),
|
reloadTabs: () => reloadSeqtaPages(),
|
||||||
|
setDevApiBase: handleSetDevApiBase,
|
||||||
extensionPages: (req) => {
|
extensionPages: (req) => {
|
||||||
browser.tabs.query({}).then((tabs) => {
|
browser.tabs.query({}).then((tabs) => {
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
@@ -469,6 +495,7 @@ function getDefaultValues(): SettingsState {
|
|||||||
adaptiveThemeColour: false,
|
adaptiveThemeColour: false,
|
||||||
adaptiveThemeGradient: false,
|
adaptiveThemeGradient: false,
|
||||||
adaptiveThemeColourTransition: true,
|
adaptiveThemeColourTransition: true,
|
||||||
|
themeOfTheMonthDisabled: false,
|
||||||
autoCloudSettingsSync: true,
|
autoCloudSettingsSync: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-24
@@ -3726,6 +3726,150 @@ div.day-empty {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.themeOfTheMonthCard {
|
||||||
|
position: fixed;
|
||||||
|
right: max(18px, env(safe-area-inset-right));
|
||||||
|
bottom: max(18px, env(safe-area-inset-bottom));
|
||||||
|
z-index: 48;
|
||||||
|
width: min(360px, calc(100vw - 36px));
|
||||||
|
overflow: visible;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--background-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||||
|
animation: themeOfTheMonthCardIn 0.24s ease-out;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCard::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardClosing {
|
||||||
|
pointer-events: none;
|
||||||
|
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardClose {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 4px !important;
|
||||||
|
right: 4px !important;
|
||||||
|
z-index: 2;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
border-radius: 16px !important;
|
||||||
|
background: rgba(0, 0, 0, 0.42);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardImage {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardBody {
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardEyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--better-pri, #6366f1) 82%, var(--text-primary) 18%);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCard h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardDescription {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin: 8px 0 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPrimary,
|
||||||
|
.themeOfTheMonthCardSecondary {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.58rem 0.9rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPrimary {
|
||||||
|
background: var(--better-pri, #6366f1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardSecondary {
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPrimary:hover,
|
||||||
|
.themeOfTheMonthCardSecondary:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.themeOfTheMonthCardPrimary:active,
|
||||||
|
.themeOfTheMonthCardSecondary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
@keyframes themeOfTheMonthCardIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes themeOfTheMonthCardOut {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.themeOfTheMonthCard {
|
||||||
|
z-index: 2147483645;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-theme-highlight {
|
||||||
|
animation: bsplusThemeHighlightPulse 1.4s ease-in-out 2;
|
||||||
|
}
|
||||||
|
@keyframes bsplusThemeHighlightPulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--better-pri, #6366f1) 0%, transparent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px color-mix(in srgb, var(--better-pri, #6366f1) 60%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.popup-media-fullscreenable {
|
.popup-media-fullscreenable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||||
@@ -4375,38 +4519,63 @@ h2.home-subtitle {
|
|||||||
|
|
||||||
.bsplus-toast {
|
.bsplus-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
right: max(18px, env(safe-area-inset-right));
|
||||||
right: 24px;
|
bottom: max(18px, env(safe-area-inset-bottom));
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
display: flex;
|
width: min(360px, calc(100vw - 36px));
|
||||||
align-items: flex-start;
|
padding: 14px 16px 16px;
|
||||||
gap: 12px;
|
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
|
||||||
max-width: 380px;
|
border-radius: 20px;
|
||||||
padding: 16px 18px;
|
background: var(--background-primary, #fff);
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--background-secondary, #fff);
|
|
||||||
color: var(--text-primary, #1a1a1a);
|
color: var(--text-primary, #1a1a1a);
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.bsplus-toast-content p {
|
.bsplus-toast-eyebrow {
|
||||||
margin: 6px 0 0;
|
margin: 0 0 6px !important;
|
||||||
opacity: 0.8;
|
font-size: 0.72rem !important;
|
||||||
font-size: 0.85rem;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%);
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
.dark .bsplus-toast-eyebrow {
|
||||||
|
color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%);
|
||||||
|
}
|
||||||
|
.bsplus-toast-content strong {
|
||||||
|
display: block;
|
||||||
|
padding-right: 34px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.bsplus-toast-content p:not(.bsplus-toast-eyebrow) {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.bsplus-toast-close {
|
.bsplus-toast-close {
|
||||||
flex-shrink: 0;
|
position: absolute !important;
|
||||||
background: none;
|
top: 4px !important;
|
||||||
border: none;
|
right: 4px !important;
|
||||||
color: var(--text-primary, #1a1a1a);
|
z-index: 2;
|
||||||
font-size: 1.3rem;
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
border-radius: 16px !important;
|
||||||
|
background: rgba(0, 0, 0, 0.42);
|
||||||
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 2px;
|
font-size: 1.35rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
opacity: 0.5;
|
transition: filter 0.15s ease;
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
}
|
||||||
.bsplus-toast-close:hover {
|
.bsplus-toast-close:hover {
|
||||||
opacity: 1;
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,34 @@
|
|||||||
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
|
import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth"
|
import { cloudAuth } from "@/seqta/utils/CloudAuth"
|
||||||
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
|
||||||
|
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
|
||||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
|
||||||
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
|
||||||
|
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
|
||||||
|
|
||||||
|
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
|
||||||
|
let devApiBaseActive = $state<string | null>(getStoredOverride())
|
||||||
|
|
||||||
|
function applyDevApiBase() {
|
||||||
|
const trimmed = devApiBaseInput.trim()
|
||||||
|
if (trimmed === "") {
|
||||||
|
setApiBase(null)
|
||||||
|
devApiBaseActive = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//.test(trimmed)) {
|
||||||
|
alert("Please enter a full URL starting with http:// or https://")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setApiBase(trimmed)
|
||||||
|
devApiBaseActive = trimmed.replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDevApiBase() {
|
||||||
|
devApiBaseInput = ""
|
||||||
|
setApiBase(null)
|
||||||
|
devApiBaseActive = null
|
||||||
|
}
|
||||||
|
|
||||||
import { getAllPluginSettings } from "@/plugins"
|
import { getAllPluginSettings } from "@/plugins"
|
||||||
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
|
||||||
@@ -483,6 +509,22 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
|
||||||
|
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
closeExtensionPopup();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
await showThemeOfTheMonthPopupNow();
|
||||||
|
}}
|
||||||
|
text="Show Now"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex justify-between items-center px-4 py-3">
|
<div class="flex justify-between items-center px-4 py-3">
|
||||||
<div class="pr-4">
|
<div class="pr-4">
|
||||||
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
|
<h2 class="text-sm font-bold">Export cloud settings JSON</h2>
|
||||||
@@ -492,6 +534,31 @@
|
|||||||
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
|
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 px-4 py-3">
|
||||||
|
<div class="flex justify-between items-start gap-3">
|
||||||
|
<div class="pr-4">
|
||||||
|
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
|
||||||
|
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
|
||||||
|
{#if devApiBaseActive}
|
||||||
|
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
|
||||||
|
Override active: <span class="font-mono">{devApiBaseActive}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://betterseqta.org"
|
||||||
|
bind:value={devApiBaseInput}
|
||||||
|
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
<Button onClick={applyDevApiBase} text="Apply" />
|
||||||
|
{#if devApiBaseActive}
|
||||||
|
<Button onClick={clearDevApiBase} text="Clear" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||||
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
|
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
|
||||||
|
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
|
||||||
|
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||||
@@ -122,13 +123,39 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function focusThemeById(themeId: string) {
|
||||||
|
const match = themes.find((t) => t.id === themeId)
|
||||||
|
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
|
||||||
|
if (match) {
|
||||||
|
activeTab = 'themes';
|
||||||
|
searchTerm = '';
|
||||||
|
displayTheme = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHighlightThemeEvent(e: Event) {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (detail?.themeId && typeof detail.themeId === 'string') {
|
||||||
|
focusThemeById(detail.themeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// On mount
|
// On mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
|
||||||
|
|
||||||
await fetchThemes();
|
await fetchThemes();
|
||||||
await fetchCurrentThemes();
|
await fetchCurrentThemes();
|
||||||
|
|
||||||
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
|
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
|
||||||
darkMode = $settingsState.DarkMode;
|
darkMode = $settingsState.DarkMode;
|
||||||
|
|
||||||
|
const pending = consumePendingHighlightThemeId();
|
||||||
|
if (pending) focusThemeById(pending);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter themes (list is already featured-first, then newest; filter preserves order)
|
// Filter themes (list is already featured-first, then newest; filter preserves order)
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
import { type Plugin } from "@/plugins/core/types";
|
import { type Plugin } from "@/plugins/core/types";
|
||||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
|
||||||
import {
|
import {
|
||||||
clearStuck,
|
clearStuck,
|
||||||
getClassByPattern,
|
getClassByPattern,
|
||||||
initStorage,
|
initStorage,
|
||||||
|
injectWeightingsTab,
|
||||||
letterToNumber,
|
letterToNumber,
|
||||||
parseAssessments,
|
parseAssessments,
|
||||||
processAssessments,
|
processAssessments,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
interface weightingsStorage {
|
interface weightingsStorage {
|
||||||
weightings: Record<string, string>;
|
weightings: Record<string, string>;
|
||||||
assessments: Record<string, string>;
|
assessments: Record<string, string>;
|
||||||
|
weightingOverrides: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
@@ -37,6 +38,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
|||||||
|
|
||||||
const instance = new AssessmentsAveragePluginClass();
|
const instance = new AssessmentsAveragePluginClass();
|
||||||
|
|
||||||
|
let overrideListenerController: AbortController | null = null;
|
||||||
|
|
||||||
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||||
id: "assessments-average",
|
id: "assessments-average",
|
||||||
name: "Assessment Averages",
|
name: "Assessment Averages",
|
||||||
@@ -58,17 +61,56 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await parseAssessments(api);
|
await parseAssessments(api);
|
||||||
|
await renderSubjectAverage(api);
|
||||||
|
overrideListenerController?.abort();
|
||||||
|
overrideListenerController = new AbortController();
|
||||||
|
document.addEventListener(
|
||||||
|
"betterseqta:overrideChanged",
|
||||||
|
() => renderSubjectAverage(api),
|
||||||
|
{ signal: overrideListenerController.signal },
|
||||||
|
);
|
||||||
|
const wrapper = document.querySelector(".assessmentsWrapper");
|
||||||
|
if (wrapper) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
applySubjectColourToOverallResult();
|
||||||
|
});
|
||||||
|
observer.observe(wrapper, { childList: true, subtree: true });
|
||||||
|
setTimeout(() => observer.disconnect(), 10000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
|
||||||
|
injectWeightingsTab(api);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderInFlight = false;
|
||||||
|
async function renderSubjectAverage(api: any) {
|
||||||
|
if (renderInFlight) return;
|
||||||
|
renderInFlight = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assessmentsList = document.querySelector(
|
||||||
|
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
||||||
|
);
|
||||||
|
if (!assessmentsList) return;
|
||||||
|
|
||||||
|
// Remove existing subject average before re-rendering
|
||||||
|
Array.from(
|
||||||
|
assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`),
|
||||||
|
)
|
||||||
|
.find((el) => el.textContent === "Subject Average")
|
||||||
|
?.closest("[class*='AssessmentItem__AssessmentItem___']")
|
||||||
|
?.remove();
|
||||||
|
|
||||||
const sampleAssessmentItem = document.querySelector(
|
const sampleAssessmentItem = document.querySelector(
|
||||||
"[class*='AssessmentItem__AssessmentItem___']",
|
"[class*='AssessmentItem__AssessmentItem___']",
|
||||||
);
|
);
|
||||||
if (!sampleAssessmentItem) return;
|
if (!sampleAssessmentItem) return;
|
||||||
|
|
||||||
const assessmentItemClass =
|
const assessmentItemClass =
|
||||||
Array.from(sampleAssessmentItem.classList).find((c) =>
|
Array.from(sampleAssessmentItem.classList).find((c) =>
|
||||||
c.startsWith("AssessmentItem__AssessmentItem___"),
|
c.startsWith("AssessmentItem__AssessmentItem___"),
|
||||||
) || "";
|
) || "";
|
||||||
|
|
||||||
const metaContainerClass = getClassByPattern(
|
const metaContainerClass = getClassByPattern(
|
||||||
sampleAssessmentItem,
|
sampleAssessmentItem,
|
||||||
"AssessmentItem__metaContainer___",
|
"AssessmentItem__metaContainer___",
|
||||||
@@ -86,35 +128,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
"AssessmentItem__title___",
|
"AssessmentItem__title___",
|
||||||
);
|
);
|
||||||
|
|
||||||
const thermoscoreElement = document.querySelector(
|
|
||||||
"[class*='Thermoscore__Thermoscore___']",
|
|
||||||
);
|
|
||||||
if (!thermoscoreElement) return;
|
|
||||||
|
|
||||||
const thermoscoreClass =
|
|
||||||
Array.from(thermoscoreElement.classList).find((c) =>
|
|
||||||
c.startsWith("Thermoscore__Thermoscore___"),
|
|
||||||
) || "";
|
|
||||||
const fillClass = getClassByPattern(
|
|
||||||
thermoscoreElement,
|
|
||||||
"Thermoscore__fill___",
|
|
||||||
);
|
|
||||||
const textClass = getClassByPattern(
|
|
||||||
thermoscoreElement,
|
|
||||||
"Thermoscore__text___",
|
|
||||||
);
|
|
||||||
|
|
||||||
const assessmentsList = document.querySelector(
|
|
||||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
|
||||||
);
|
|
||||||
if (!assessmentsList) return;
|
|
||||||
|
|
||||||
const state = await ReactFiber.find(
|
|
||||||
"[class*='AssessmentList__items___']",
|
|
||||||
).getState();
|
|
||||||
const marks = state["marks"];
|
|
||||||
if (!marks || !marks.length) return;
|
|
||||||
|
|
||||||
const assessmentItems = Array.from(
|
const assessmentItems = Array.from(
|
||||||
assessmentsList.querySelectorAll(
|
assessmentsList.querySelectorAll(
|
||||||
`[class*='AssessmentItem__AssessmentItem___']`,
|
`[class*='AssessmentItem__AssessmentItem___']`,
|
||||||
@@ -128,9 +141,25 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
|
|
||||||
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
||||||
await processAssessments(api, assessmentItems);
|
await processAssessments(api, assessmentItems);
|
||||||
|
|
||||||
if (!count || totalWeight === 0) return;
|
if (!count || totalWeight === 0) return;
|
||||||
|
|
||||||
|
const thermoscoreElement = document.querySelector(
|
||||||
|
"[class*='Thermoscore__Thermoscore___']",
|
||||||
|
);
|
||||||
|
if (!thermoscoreElement) return;
|
||||||
|
const thermoscoreClass =
|
||||||
|
Array.from(thermoscoreElement.classList).find((c) =>
|
||||||
|
c.startsWith("Thermoscore__Thermoscore___"),
|
||||||
|
) || "";
|
||||||
|
const fillClass = getClassByPattern(
|
||||||
|
thermoscoreElement,
|
||||||
|
"Thermoscore__fill___",
|
||||||
|
);
|
||||||
|
const textClass = getClassByPattern(
|
||||||
|
thermoscoreElement,
|
||||||
|
"Thermoscore__text___",
|
||||||
|
);
|
||||||
|
|
||||||
const avg = weightedTotal / totalWeight;
|
const avg = weightedTotal / totalWeight;
|
||||||
const rounded = Math.ceil(avg / 5) * 5;
|
const rounded = Math.ceil(avg / 5) * 5;
|
||||||
const numberToLetter = Object.entries(letterToNumber).reduce(
|
const numberToLetter = Object.entries(letterToNumber).reduce(
|
||||||
@@ -140,17 +169,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
},
|
},
|
||||||
{} as Record<number, string>,
|
{} as Record<number, string>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
||||||
const display = api.settings.lettergrade
|
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
|
||||||
? letterAvg
|
|
||||||
: `${avg.toFixed(2)}%`;
|
|
||||||
|
|
||||||
const existing = assessmentsList.querySelector(
|
|
||||||
`[class*='AssessmentItem__title___']`,
|
|
||||||
);
|
|
||||||
if (existing?.textContent === "Subject Average") return;
|
|
||||||
|
|
||||||
let warningHTML = "";
|
let warningHTML = "";
|
||||||
if (hasInaccurateWeighting) {
|
if (hasInaccurateWeighting) {
|
||||||
warningHTML = /* html */ `
|
warningHTML = /* html */ `
|
||||||
@@ -159,7 +179,6 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
assessmentsList.insertBefore(
|
assessmentsList.insertBefore(
|
||||||
stringToHTML(/* html */ `
|
stringToHTML(/* html */ `
|
||||||
<div class="${assessmentItemClass}">
|
<div class="${assessmentItemClass}">
|
||||||
@@ -180,21 +199,11 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
`).firstChild!,
|
`).firstChild!,
|
||||||
assessmentsList.firstChild,
|
assessmentsList.firstChild,
|
||||||
);
|
);
|
||||||
|
|
||||||
applySubjectColourToOverallResult();
|
applySubjectColourToOverallResult();
|
||||||
|
} finally {
|
||||||
const observer = new MutationObserver(() => {
|
renderInFlight = false;
|
||||||
applySubjectColourToOverallResult();
|
|
||||||
});
|
|
||||||
const wrapper = document.querySelector(".assessmentsWrapper");
|
|
||||||
if (wrapper) {
|
|
||||||
observer.observe(wrapper, { childList: true, subtree: true });
|
|
||||||
setTimeout(() => observer.disconnect(), 10000);
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function applySubjectColourToOverallResult() {
|
function applySubjectColourToOverallResult() {
|
||||||
const selectedAssessmentItem = document.querySelector(
|
const selectedAssessmentItem = document.querySelector(
|
||||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export async function initStorage(api: any) {
|
|||||||
if (!api.storage.assessments) {
|
if (!api.storage.assessments) {
|
||||||
api.storage.assessments = {};
|
api.storage.assessments = {};
|
||||||
}
|
}
|
||||||
|
if (!api.storage.weightingOverrides) {
|
||||||
|
api.storage.weightingOverrides = {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearStuck(api: any) {
|
export function clearStuck(api: any) {
|
||||||
@@ -79,52 +82,84 @@ function createWeightLabel(
|
|||||||
assessmentItem: Element,
|
assessmentItem: Element,
|
||||||
weighting: string | undefined,
|
weighting: string | undefined,
|
||||||
) {
|
) {
|
||||||
const statsContainer = assessmentItem.querySelector(
|
let statsContainer = assessmentItem.querySelector(
|
||||||
`[class*='AssessmentItem__stats___']`,
|
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||||
) as HTMLElement;
|
) as HTMLElement | null;
|
||||||
|
|
||||||
if (
|
if (!statsContainer) {
|
||||||
!statsContainer ||
|
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
|
||||||
statsContainer.querySelector(".betterseqta-weight-label")
|
statsContainer = document.createElement("div");
|
||||||
)
|
statsContainer.className = statsClass;
|
||||||
return;
|
statsContainer.classList.add("betterseqta-stats-container");
|
||||||
|
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
|
||||||
|
if (thermoscore) {
|
||||||
|
thermoscore.insertAdjacentElement("afterend", statsContainer);
|
||||||
|
} else {
|
||||||
|
assessmentItem.appendChild(statsContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const label = statsContainer.querySelector(
|
const hasNativeLabel = !!statsContainer.querySelector(
|
||||||
`[class*='Label__Label___']`,
|
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
|
||||||
) as HTMLElement;
|
|
||||||
|
|
||||||
if (!label) return;
|
|
||||||
|
|
||||||
const weightLabel = label.cloneNode(true) as HTMLElement;
|
|
||||||
weightLabel.classList.add("betterseqta-weight-label");
|
|
||||||
|
|
||||||
const innerTextDiv = weightLabel.querySelector(
|
|
||||||
`[class*='Label__innerText___']`,
|
|
||||||
);
|
);
|
||||||
|
statsContainer.style.justifyContent = hasNativeLabel
|
||||||
|
? "space-between"
|
||||||
|
: "flex-end";
|
||||||
|
|
||||||
|
const displayText =
|
||||||
|
weighting && weighting !== "processing" && weighting !== "N/A"
|
||||||
|
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
|
||||||
|
: "N/A";
|
||||||
|
|
||||||
|
const existingLabel = statsContainer.querySelector(
|
||||||
|
".betterseqta-weight-label",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (existingLabel) {
|
||||||
|
const textNodes = Array.from(existingLabel.childNodes).filter(
|
||||||
|
(node) => node.nodeType === Node.TEXT_NODE,
|
||||||
|
);
|
||||||
|
if (textNodes.length) textNodes[0].textContent = displayText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statsContainer.style.display = "flex";
|
||||||
|
statsContainer.style.alignItems = "center";
|
||||||
|
statsContainer.style.width = "100%";
|
||||||
|
|
||||||
|
// Try to clone an existing label from the stats container first,
|
||||||
|
// fall back to building from scratch if none exists
|
||||||
|
const existingNativeLabel = statsContainer.querySelector(
|
||||||
|
`[class*='Label__Label___']`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
const weightLabel = existingNativeLabel
|
||||||
|
? (existingNativeLabel.cloneNode(true) as HTMLElement)
|
||||||
|
: (() => {
|
||||||
|
const labelClass = getClassByPattern(document, "Label__Label___");
|
||||||
|
const innerTextClass = getClassByPattern(document, "Label__innerText___");
|
||||||
|
const el = document.createElement("label");
|
||||||
|
el.className = labelClass;
|
||||||
|
el.innerHTML = `<div class="${innerTextClass}">Weight</div>`;
|
||||||
|
return el;
|
||||||
|
})();
|
||||||
|
|
||||||
|
weightLabel.classList.add("betterseqta-weight-label");
|
||||||
|
weightLabel.style.flex = "none";
|
||||||
|
weightLabel.style.width = "fit-content";
|
||||||
|
|
||||||
|
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
|
||||||
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
||||||
|
|
||||||
const textNodes = Array.from(weightLabel.childNodes).filter(
|
const textNodes = Array.from(weightLabel.childNodes).filter(
|
||||||
(node) => node.nodeType === Node.TEXT_NODE,
|
(node) => node.nodeType === Node.TEXT_NODE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (textNodes.length) {
|
if (textNodes.length) {
|
||||||
textNodes[0].textContent =
|
textNodes[0].textContent = displayText;
|
||||||
weighting && weighting !== "processing"
|
} else {
|
||||||
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
|
weightLabel.appendChild(document.createTextNode(displayText));
|
||||||
: "N/A";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
|
|
||||||
statsContainer.style.display = "flex";
|
|
||||||
statsContainer.style.flexDirection = "column";
|
|
||||||
statsContainer.style.alignItems = "flex-end";
|
|
||||||
statsContainer.style.gap = "2px";
|
|
||||||
statsContainer.style.justifyContent = "center";
|
|
||||||
|
|
||||||
weightLabel.style.position = "relative";
|
|
||||||
weightLabel.style.inset = "unset";
|
|
||||||
weightLabel.style.transform = "none";
|
|
||||||
|
|
||||||
statsContainer.appendChild(weightLabel);
|
statsContainer.appendChild(weightLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +263,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
|||||||
export async function extractPDFText(url: string): Promise<string> {
|
export async function extractPDFText(url: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
if (isFirefox) {
|
if (isFirefox) {
|
||||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
|
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
|
||||||
|
getPdfjsPageContextUrls();
|
||||||
const escJsSingleQuoted = (s: string) =>
|
const escJsSingleQuoted = (s: string) =>
|
||||||
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||||
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||||
@@ -519,7 +555,11 @@ export async function parseAssessments(api: any) {
|
|||||||
"[class*='AssessmentList__items___']",
|
"[class*='AssessmentList__items___']",
|
||||||
).getState();
|
).getState();
|
||||||
|
|
||||||
const marks = state["marks"];
|
const marks = [
|
||||||
|
...(state["marks"] ?? []),
|
||||||
|
...(state["upcoming"] ?? []),
|
||||||
|
...(state["pending"] ?? []),
|
||||||
|
];
|
||||||
if (!marks) return;
|
if (!marks) return;
|
||||||
|
|
||||||
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
||||||
@@ -532,15 +572,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const assessmentItem of assessmentItems) {
|
for (const assessmentItem of assessmentItems) {
|
||||||
const gradeElement = assessmentItem.querySelector(
|
|
||||||
`[class*='Thermoscore__text___']`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!gradeElement) continue;
|
|
||||||
|
|
||||||
const grade = parseGrade(gradeElement.textContent || "");
|
|
||||||
if (grade <= 0) continue;
|
|
||||||
|
|
||||||
const titleEl = assessmentItem.querySelector(
|
const titleEl = assessmentItem.querySelector(
|
||||||
`[class*='AssessmentItem__title___']`,
|
`[class*='AssessmentItem__title___']`,
|
||||||
);
|
);
|
||||||
@@ -550,12 +581,23 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
if (!title) continue;
|
if (!title) continue;
|
||||||
|
|
||||||
const assessmentID = api.storage.assessments?.[title];
|
const assessmentID = api.storage.assessments?.[title];
|
||||||
const weighting = assessmentID
|
const autoWeighting = assessmentID
|
||||||
? api.storage.weightings?.[assessmentID]
|
? api.storage.weightings?.[assessmentID]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const override = assessmentID
|
||||||
|
? api.storage.weightingOverrides?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
const weighting = override ?? autoWeighting;
|
||||||
|
|
||||||
createWeightLabel(assessmentItem, weighting);
|
createWeightLabel(assessmentItem, weighting);
|
||||||
|
|
||||||
|
const gradeElement = assessmentItem.querySelector(
|
||||||
|
`[class*='Thermoscore__text___']`,
|
||||||
|
);
|
||||||
|
if (!gradeElement) continue;
|
||||||
|
const grade = parseGrade(gradeElement.textContent || "");
|
||||||
|
if (grade <= 0) continue;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
weighting === null ||
|
weighting === null ||
|
||||||
weighting === undefined ||
|
weighting === undefined ||
|
||||||
@@ -563,8 +605,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
weighting === "processing"
|
weighting === "processing"
|
||||||
) {
|
) {
|
||||||
hasInaccurateWeighting = true;
|
hasInaccurateWeighting = true;
|
||||||
weightedTotal += grade;
|
continue
|
||||||
totalWeight += 1;
|
|
||||||
} else {
|
} else {
|
||||||
const weight = parseFloat(weighting);
|
const weight = parseFloat(weighting);
|
||||||
|
|
||||||
@@ -587,3 +628,271 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
count,
|
count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTabSetClasses(): Record<string, string> {
|
||||||
|
const patterns = [
|
||||||
|
"TabSet__tabsheet___",
|
||||||
|
"TabSet__hidden___",
|
||||||
|
"TabSet__selected___",
|
||||||
|
"TabSet__disappearToLeft___",
|
||||||
|
"TabSet__disappearToRight___",
|
||||||
|
"TabSet__appearFromRight___",
|
||||||
|
"TabSet__appearFromLeft___",
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved: Record<string, string> = {};
|
||||||
|
|
||||||
|
// First pass: scan live DOM elements (fast, covers currently-applied classes)
|
||||||
|
const allClasses = Array.from(
|
||||||
|
document.querySelectorAll('[class*="TabSet__"]'),
|
||||||
|
).flatMap((el) => Array.from(el.classList));
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const found = allClasses.find((c) => c.startsWith(pattern));
|
||||||
|
if (found) resolved[pattern] = found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: scan stylesheets for any classes not yet in the DOM
|
||||||
|
// (e.g. animation classes that haven't been applied yet)
|
||||||
|
const missing = patterns.filter((p) => !resolved[p]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
try {
|
||||||
|
for (const sheet of Array.from(document.styleSheets)) {
|
||||||
|
if (missing.every((p) => resolved[p])) break;
|
||||||
|
try {
|
||||||
|
for (const rule of Array.from(sheet.cssRules ?? [])) {
|
||||||
|
if (!(rule instanceof CSSStyleRule)) continue;
|
||||||
|
const selectorClasses =
|
||||||
|
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
|
||||||
|
for (const pattern of missing) {
|
||||||
|
if (!resolved[pattern]) {
|
||||||
|
const match = selectorClasses.find((c) =>
|
||||||
|
c.slice(1).startsWith(pattern),
|
||||||
|
);
|
||||||
|
if (match) resolved[pattern] = match.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cross-origin stylesheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use the base pattern as-is so the function doesn't crash,
|
||||||
|
// though styles won't apply if the hash is truly unknown.
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (!resolved[pattern]) resolved[pattern] = pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
||||||
|
const titleEl = document.querySelector(
|
||||||
|
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
|
||||||
|
);
|
||||||
|
const title = titleEl?.textContent?.trim();
|
||||||
|
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||||
|
|
||||||
|
const rawWeight = assessmentID
|
||||||
|
? api.storage.weightings?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const weightingUnavailable = rawWeight === "N/A";
|
||||||
|
|
||||||
|
const autoWeight =
|
||||||
|
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
|
||||||
|
? rawWeight
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const override = assessmentID
|
||||||
|
? api.storage.weightingOverrides?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const statusNote = !assessmentID
|
||||||
|
? ""
|
||||||
|
: rawWeight === "processing"
|
||||||
|
? "Weighting is still being detected."
|
||||||
|
: weightingUnavailable
|
||||||
|
? "No weighting was found in the marksheet. Set one manually."
|
||||||
|
: "Overrides the auto-detected value.";
|
||||||
|
|
||||||
|
sheet.innerHTML = `
|
||||||
|
<style>
|
||||||
|
#betterseqta-weight-override::placeholder {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div style="padding:16px;max-width:360px">
|
||||||
|
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
|
||||||
|
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
|
||||||
|
Set the weighting for this assessment.
|
||||||
|
${statusNote}
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||||
|
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
|
||||||
|
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
|
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">Override %</label>
|
||||||
|
<input
|
||||||
|
id="betterseqta-weight-override"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="5"
|
||||||
|
placeholder="${autoWeight ?? ""}"
|
||||||
|
value="${override ?? ""}"
|
||||||
|
${!assessmentID ? "disabled" : ""}
|
||||||
|
style="
|
||||||
|
width:90px;
|
||||||
|
padding:5px 8px;
|
||||||
|
border-radius:6px;
|
||||||
|
border:1px solid rgba(128,128,128,0.3);
|
||||||
|
background:rgba(128,128,128,0.08);
|
||||||
|
color:inherit;
|
||||||
|
font-size:13px;
|
||||||
|
outline:none;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px;min-height:18px">
|
||||||
|
<span class="betterseqta-save-status" style="font-size:12px;opacity:0.5"></span>
|
||||||
|
</div>
|
||||||
|
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!assessmentID) return;
|
||||||
|
|
||||||
|
const input = sheet.querySelector(
|
||||||
|
"#betterseqta-weight-override",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const statusEl = sheet.querySelector(
|
||||||
|
".betterseqta-save-status",
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const raw = input.value.trim();
|
||||||
|
if (raw === "") {
|
||||||
|
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
|
||||||
|
api.storage.weightingOverrides = rest;
|
||||||
|
} else {
|
||||||
|
const val = parseFloat(raw);
|
||||||
|
if (isNaN(val) || val < 0) {
|
||||||
|
input.style.borderColor = "rgba(255,80,80,0.6)";
|
||||||
|
statusEl.textContent = "Invalid. Must be 0 or greater";
|
||||||
|
statusEl.style.color = "rgba(255,80,80,0.8)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.style.borderColor = "rgba(128,128,128,0.3)";
|
||||||
|
api.storage.weightingOverrides = {
|
||||||
|
...api.storage.weightingOverrides,
|
||||||
|
[assessmentID]: String(val),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
statusEl.textContent = "Saved";
|
||||||
|
statusEl.style.color = "";
|
||||||
|
setTimeout(() => (statusEl.textContent = ""), 2000);
|
||||||
|
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("blur", save);
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
input.blur();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
input.style.borderColor = "rgba(128,128,128,0.3)";
|
||||||
|
if (statusEl.textContent === "Invalid. Must be 0 or greater.")
|
||||||
|
statusEl.textContent = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectWeightingsTab(api: any) {
|
||||||
|
const tabList = document.querySelector(
|
||||||
|
'[class*="TabSet__tabs___"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
const container = document.querySelector(
|
||||||
|
'[class*="TabSet__tabContainer___"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!tabList || !container) return;
|
||||||
|
if (tabList.querySelector(".betterseqta-weightings-tab")) return;
|
||||||
|
|
||||||
|
const cls = resolveTabSetClasses();
|
||||||
|
|
||||||
|
const prefix = (tabList.querySelector("li") as HTMLElement).id.replace(
|
||||||
|
/-tab-\d+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const newIndex = tabList.querySelectorAll("li").length;
|
||||||
|
|
||||||
|
const newTab = document.createElement("li");
|
||||||
|
newTab.id = `${prefix}-tab-${newIndex}`;
|
||||||
|
newTab.className = "";
|
||||||
|
newTab.setAttribute("aria-selected", "false");
|
||||||
|
newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`);
|
||||||
|
newTab.classList.add("betterseqta-weightings-tab");
|
||||||
|
newTab.textContent = "Weightings";
|
||||||
|
tabList.appendChild(newTab);
|
||||||
|
|
||||||
|
const newSheet = document.createElement("div");
|
||||||
|
newSheet.id = `${prefix}-tabsheet-${newIndex}`;
|
||||||
|
newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`);
|
||||||
|
newSheet.className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__hidden___"],
|
||||||
|
cls["TabSet__disappearToRight___"],
|
||||||
|
].join(" ");
|
||||||
|
container.appendChild(newSheet);
|
||||||
|
|
||||||
|
let populated = false;
|
||||||
|
newTab.addEventListener("click", () => {
|
||||||
|
if (!populated) {
|
||||||
|
buildWeightingsTabContent(api, newSheet);
|
||||||
|
populated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTabs = Array.from(tabList.querySelectorAll("li"));
|
||||||
|
const allSheets = Array.from(
|
||||||
|
container.querySelectorAll('[class*="tabsheet"]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
allTabs.forEach((tab, i) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
const currentIndex = allTabs.findIndex((t) =>
|
||||||
|
t.className.includes("TabSet__selected___"),
|
||||||
|
);
|
||||||
|
if (i === currentIndex) return;
|
||||||
|
const goingRight = i > currentIndex;
|
||||||
|
|
||||||
|
allTabs.forEach((t) => {
|
||||||
|
t.className = "";
|
||||||
|
t.setAttribute("aria-selected", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
allSheets[currentIndex].className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__hidden___"],
|
||||||
|
goingRight
|
||||||
|
? cls["TabSet__disappearToLeft___"]
|
||||||
|
: cls["TabSet__disappearToRight___"],
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
allSheets[i].className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__selected___"],
|
||||||
|
goingRight
|
||||||
|
? cls["TabSet__appearFromRight___"]
|
||||||
|
: cls["TabSet__appearFromLeft___"],
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
tab.className = cls["TabSet__selected___"];
|
||||||
|
tab.setAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,17 +28,9 @@ async function fetchJSON(url: string, body: any) {
|
|||||||
|
|
||||||
async function loadSubjects() {
|
async function loadSubjects() {
|
||||||
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
||||||
const activeGroup = res.payload.find((s: any) => s.active === 1);
|
return res.payload
|
||||||
const activeYear = activeGroup?.year;
|
.filter((s: any) => s.active === 1)
|
||||||
const allSubjects = res.payload
|
|
||||||
.filter((s: any) => s.year === activeYear)
|
|
||||||
.flatMap((s: any) => s.subjects);
|
.flatMap((s: any) => s.subjects);
|
||||||
const seen = new Set<string>();
|
|
||||||
return allSubjects.filter((s: Subject) => {
|
|
||||||
if (seen.has(s.code)) return false;
|
|
||||||
seen.add(s.code);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPrefs(student: number) {
|
async function loadPrefs(student: number) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface Folder {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
emoji: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageFoldersStorage {
|
interface MessageFoldersStorage {
|
||||||
@@ -34,12 +35,33 @@ const FOLDER_COLORS = [
|
|||||||
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
|
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FOLDER_HEROICONS = [
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`,
|
||||||
|
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
|
||||||
|
];
|
||||||
|
|
||||||
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
|
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
|
||||||
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
|
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
|
||||||
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
|
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
|
||||||
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
|
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
|
||||||
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
|
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
|
||||||
|
const CHEVRON_SVG = `<svg style="width:12px;height:12px;flex-shrink:0;transition:transform .2s" viewBox="0 0 24 24"><path fill="#888" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>`;
|
||||||
|
const DRAG_SVG = `<svg style="width:14px;height:14px;flex-shrink:0;cursor:grab" viewBox="0 0 24 24"><path fill="#888" d="M6.5 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/></svg>`;
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||||
@@ -49,7 +71,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
id: "messageFolders",
|
id: "messageFolders",
|
||||||
name: "Message Folders",
|
name: "Message Folders",
|
||||||
description: "Organize direct messages into custom folders",
|
description: "Organize direct messages into custom folders",
|
||||||
version: "1.0.0",
|
version: "2.0.0",
|
||||||
settings: messageFoldersSettings,
|
settings: messageFoldersSettings,
|
||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
defaultEnabled: true,
|
defaultEnabled: true,
|
||||||
@@ -70,10 +92,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
let actionsObserver: MutationObserver | null = null;
|
let actionsObserver: MutationObserver | null = null;
|
||||||
let openDropdown: HTMLElement | null = null;
|
let openDropdown: HTMLElement | null = null;
|
||||||
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
|
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
|
let foldedSection: HTMLElement | null = null;
|
||||||
const unregisters: Array<{ unregister: () => void }> = [];
|
const unregisters: Array<{ unregister: () => void }> = [];
|
||||||
|
|
||||||
// ── Storage accessors ──
|
|
||||||
|
|
||||||
const getFolders = (): Folder[] => api.storage.folders ?? [];
|
const getFolders = (): Folder[] => api.storage.folders ?? [];
|
||||||
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
|
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
|
||||||
|
|
||||||
@@ -94,6 +115,18 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
return ids;
|
return ids;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assignMessageToFolder = (messageId: string, folderId: string, add: boolean) => {
|
||||||
|
const assignments = getAssignments();
|
||||||
|
if (!assignments[folderId]) assignments[folderId] = [];
|
||||||
|
const idx = assignments[folderId].indexOf(messageId);
|
||||||
|
if (add && idx < 0) {
|
||||||
|
assignments[folderId].push(messageId);
|
||||||
|
} else if (!add && idx >= 0) {
|
||||||
|
assignments[folderId].splice(idx, 1);
|
||||||
|
}
|
||||||
|
saveAssignments(assignments);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleMessageInFolder = (messageId: string, folderId: string) => {
|
const toggleMessageInFolder = (messageId: string, folderId: string) => {
|
||||||
const assignments = getAssignments();
|
const assignments = getAssignments();
|
||||||
if (!assignments[folderId]) assignments[folderId] = [];
|
if (!assignments[folderId]) assignments[folderId] = [];
|
||||||
@@ -129,16 +162,28 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
return api.settings.showTagsInAllMessages || activeFolderId !== null;
|
return api.settings.showTagsInAllMessages || activeFolderId !== null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Confirm modal ──
|
const getSelectedMessageId = (): string | null => {
|
||||||
|
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
|
||||||
|
return selectedMsg?.getAttribute("data-message") ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
const showConfirmModal = (
|
const getMessageIdFromEvent = (target: HTMLElement): string | null => {
|
||||||
title: string,
|
const li = target.closest("li[data-message]");
|
||||||
message: string,
|
return li?.getAttribute("data-message") ?? null;
|
||||||
onConfirm: () => void,
|
};
|
||||||
) => {
|
|
||||||
|
const getAllVisibleMessageIds = (): string[] => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
|
||||||
|
const id = li.getAttribute("data-message");
|
||||||
|
if (id) ids.push(id);
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showConfirmModal = (title: string, message: string, onConfirm: () => void) => {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.className = "bsplus-modal-overlay";
|
overlay.className = "bsplus-modal-overlay";
|
||||||
|
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
modal.className = "bsplus-modal";
|
modal.className = "bsplus-modal";
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
@@ -150,16 +195,13 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
overlay.appendChild(modal);
|
overlay.appendChild(modal);
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
document.removeEventListener("keydown", onKey);
|
document.removeEventListener("keydown", onKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") remove();
|
if (e.key === "Escape") remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
overlay.addEventListener("click", (e) => {
|
overlay.addEventListener("click", (e) => {
|
||||||
if (e.target === overlay) remove();
|
if (e.target === overlay) remove();
|
||||||
});
|
});
|
||||||
@@ -168,36 +210,42 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
onConfirm();
|
onConfirm();
|
||||||
remove();
|
remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
document.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Sidebar folder UI ──
|
|
||||||
|
|
||||||
const renderSidebarFolders = () => {
|
const renderSidebarFolders = () => {
|
||||||
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
if (!sidebar) return;
|
if (!sidebar) return;
|
||||||
|
|
||||||
const ol = sidebar.querySelector("ol");
|
const ol = sidebar.querySelector("ol");
|
||||||
if (!ol) return;
|
if (!ol) return;
|
||||||
|
|
||||||
let section = ol.querySelector(".bsplus-folders-section");
|
let section = ol.querySelector(".bsplus-folders-section") as HTMLElement;
|
||||||
if (!section) {
|
if (!section) {
|
||||||
section = document.createElement("div");
|
section = document.createElement("div");
|
||||||
section.className = "bsplus-folders-section";
|
section.className = "bsplus-folders-section";
|
||||||
ol.appendChild(section);
|
ol.appendChild(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foldedSection = section;
|
||||||
const folders = getFolders();
|
const folders = getFolders();
|
||||||
const existingInput = section.querySelector(".bsplus-folder-input");
|
|
||||||
const existingColors = section.querySelector(".bsplus-folder-colors");
|
|
||||||
|
|
||||||
section.innerHTML = "";
|
section.innerHTML = "";
|
||||||
|
|
||||||
// Header
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "bsplus-folders-header";
|
header.className = "bsplus-folders-header";
|
||||||
|
header.dataset.folded = "false";
|
||||||
|
|
||||||
|
const collapseBtn = document.createElement("button");
|
||||||
|
collapseBtn.className = "bsplus-folders-collapse";
|
||||||
|
collapseBtn.innerHTML = CHEVRON_SVG;
|
||||||
|
collapseBtn.title = "Collapse";
|
||||||
|
collapseBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isFolded = collapseBtn.classList.toggle("bsplus-folded");
|
||||||
|
section.classList.toggle("bsplus-section-folded", isFolded);
|
||||||
|
collapseBtn.title = isFolded ? "Expand" : "Collapse";
|
||||||
|
});
|
||||||
|
header.appendChild(collapseBtn);
|
||||||
|
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.textContent = "Folders";
|
label.textContent = "Folders";
|
||||||
@@ -214,9 +262,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
header.appendChild(addBtn);
|
header.appendChild(addBtn);
|
||||||
section.appendChild(header);
|
section.appendChild(header);
|
||||||
|
|
||||||
// "All Messages" item
|
|
||||||
const allItem = document.createElement("div");
|
const allItem = document.createElement("div");
|
||||||
allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
|
allItem.className = `bsplus-folder-item bsplus-all-msgs${activeFolderId === null ? " bsplus-folder-active" : ""}`;
|
||||||
allItem.innerHTML = `
|
allItem.innerHTML = `
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||||
<span class="bsplus-folder-name">All Messages</span>
|
<span class="bsplus-folder-name">All Messages</span>
|
||||||
@@ -226,20 +273,34 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
applyFolderFilter();
|
applyFolderFilter();
|
||||||
applyBadges();
|
applyBadges();
|
||||||
renderSidebarFolders();
|
renderSidebarFolders();
|
||||||
|
setTimeout(() => {
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
section.appendChild(allItem);
|
section.appendChild(allItem);
|
||||||
|
|
||||||
// Folder items
|
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
|
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
|
||||||
item.dataset.folderId = folder.id;
|
item.dataset.folderId = folder.id;
|
||||||
|
item.draggable = true;
|
||||||
|
|
||||||
|
const dragHandle = document.createElement("div");
|
||||||
|
dragHandle.className = "bsplus-folder-drag";
|
||||||
|
dragHandle.innerHTML = DRAG_SVG;
|
||||||
|
item.appendChild(dragHandle);
|
||||||
|
|
||||||
const dot = document.createElement("div");
|
const dot = document.createElement("div");
|
||||||
dot.className = "bsplus-folder-dot";
|
dot.className = "bsplus-folder-dot";
|
||||||
dot.style.background = folder.color;
|
dot.style.background = folder.color;
|
||||||
item.appendChild(dot);
|
item.appendChild(dot);
|
||||||
|
|
||||||
|
const iconSpan = document.createElement("span");
|
||||||
|
iconSpan.className = "bsplus-folder-icon";
|
||||||
|
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||||
|
item.appendChild(iconSpan);
|
||||||
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.className = "bsplus-folder-name";
|
name.className = "bsplus-folder-name";
|
||||||
name.textContent = folder.name;
|
name.textContent = folder.name;
|
||||||
@@ -264,10 +325,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
deleteBtn.innerHTML = TRASH_SVG;
|
deleteBtn.innerHTML = TRASH_SVG;
|
||||||
deleteBtn.addEventListener("click", (e) => {
|
deleteBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
showConfirmModal(
|
showConfirmModal("Delete folder", `Remove "${folder.name}"? Messages won't be deleted.`, () => {
|
||||||
"Delete folder",
|
|
||||||
`Remove "${folder.name}"? Messages won't be deleted.`,
|
|
||||||
() => {
|
|
||||||
const folders = getFolders().filter((f) => f.id !== folder.id);
|
const folders = getFolders().filter((f) => f.id !== folder.id);
|
||||||
saveFolders(folders);
|
saveFolders(folders);
|
||||||
const assignments = getAssignments();
|
const assignments = getAssignments();
|
||||||
@@ -277,8 +335,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
applyFolderFilter();
|
applyFolderFilter();
|
||||||
applyBadges();
|
applyBadges();
|
||||||
renderSidebarFolders();
|
renderSidebarFolders();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
actions.appendChild(deleteBtn);
|
actions.appendChild(deleteBtn);
|
||||||
|
|
||||||
@@ -295,15 +352,89 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
applyFolderFilter();
|
applyFolderFilter();
|
||||||
applyBadges();
|
applyBadges();
|
||||||
renderSidebarFolders();
|
renderSidebarFolders();
|
||||||
|
setTimeout(() => {
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.addEventListener("dragstart", (e) => {
|
||||||
|
e.dataTransfer?.setData("text/plain", `reorder:${folder.id}`);
|
||||||
|
item.classList.add("bsplus-dragging");
|
||||||
|
});
|
||||||
|
item.addEventListener("dragend", () => {
|
||||||
|
item.classList.remove("bsplus-dragging");
|
||||||
|
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
|
||||||
|
});
|
||||||
|
item.addEventListener("dragover", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = e.dataTransfer?.getData("text/plain") || "";
|
||||||
|
if (data.startsWith("reorder:") && !data.includes(folder.id)) {
|
||||||
|
item.classList.add("bsplus-drag-over");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
item.addEventListener("dragleave", () => {
|
||||||
|
item.classList.remove("bsplus-drag-over");
|
||||||
|
});
|
||||||
|
item.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
item.classList.remove("bsplus-drag-over");
|
||||||
|
const data = e.dataTransfer?.getData("text/plain") || "";
|
||||||
|
if (data.startsWith("reorder:")) {
|
||||||
|
const draggedId = data.replace("reorder:", "");
|
||||||
|
const folders = getFolders();
|
||||||
|
const draggedIdx = folders.findIndex((f) => f.id === draggedId);
|
||||||
|
const targetIdx = folders.findIndex((f) => f.id === folder.id);
|
||||||
|
if (draggedIdx >= 0 && targetIdx >= 0 && draggedIdx !== targetIdx) {
|
||||||
|
const [removed] = folders.splice(draggedIdx, 1);
|
||||||
|
folders.splice(targetIdx, 0, removed);
|
||||||
|
saveFolders(folders);
|
||||||
|
renderSidebarFolders();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
section.appendChild(item);
|
section.appendChild(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore input if it was open
|
section.addEventListener("dragover", (e) => {
|
||||||
if (existingInput || existingColors) {
|
e.preventDefault();
|
||||||
// Don't restore – let user re-trigger
|
});
|
||||||
|
section.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = e.dataTransfer?.getData("text/plain") || "";
|
||||||
|
if (data.startsWith("msg:")) {
|
||||||
|
const messageId = data.replace("msg:", "");
|
||||||
|
const folderId = (e.target as HTMLElement).closest("[data-folder-id]")?.getAttribute("data-folder-id");
|
||||||
|
if (messageId && folderId) {
|
||||||
|
assignMessageToFolder(messageId, folderId, true);
|
||||||
|
applyBadges();
|
||||||
|
applyFolderFilter();
|
||||||
|
renderSidebarFolders();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
attachDragListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachDragListeners = () => {
|
||||||
|
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
|
||||||
|
if (li.getAttribute("data-bsplus-drag") === "true") return;
|
||||||
|
li.setAttribute("data-bsplus-drag", "true");
|
||||||
|
li.draggable = true;
|
||||||
|
li.addEventListener("dragstart", (e) => {
|
||||||
|
const id = li.getAttribute("data-message");
|
||||||
|
if (id) {
|
||||||
|
e.dataTransfer?.setData("text/plain", `msg:${id}`);
|
||||||
|
li.classList.add("bsplus-msg-dragging");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
li.addEventListener("dragend", () => {
|
||||||
|
li.classList.remove("bsplus-msg-dragging");
|
||||||
|
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
|
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
|
||||||
@@ -312,16 +443,34 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
container.querySelector(".bsplus-folder-colors")?.remove();
|
container.querySelector(".bsplus-folder-colors")?.remove();
|
||||||
|
|
||||||
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
|
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
|
||||||
|
let selectedIcon = editFolder?.emoji ?? FOLDER_HEROICONS[Math.floor(Math.random() * FOLDER_HEROICONS.length)];
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "bsplus-folder-input";
|
row.className = "bsplus-folder-input";
|
||||||
|
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "text";
|
input.type = "text";
|
||||||
input.placeholder = editFolder ? "Rename folder…" : "Folder name…";
|
input.placeholder = editFolder ? "Rename folder\u2026" : "Folder name\u2026";
|
||||||
input.value = editFolder?.name ?? "";
|
input.value = editFolder?.name ?? "";
|
||||||
input.maxLength = 30;
|
input.maxLength = 30;
|
||||||
|
|
||||||
|
const iconBtn = document.createElement("button");
|
||||||
|
iconBtn.className = "bsplus-folder-icon-btn";
|
||||||
|
iconBtn.title = "Pick icon";
|
||||||
|
iconBtn.innerHTML = selectedIcon;
|
||||||
|
iconBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const picker = container.querySelector(".bsplus-icon-picker") as HTMLElement | null;
|
||||||
|
if (picker) {
|
||||||
|
picker.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showIconPicker(container, (iconSvg) => {
|
||||||
|
selectedIcon = iconSvg;
|
||||||
|
iconBtn.innerHTML = iconSvg;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const confirmBtn = document.createElement("button");
|
const confirmBtn = document.createElement("button");
|
||||||
confirmBtn.className = "bsplus-folder-input-confirm";
|
confirmBtn.className = "bsplus-folder-input-confirm";
|
||||||
confirmBtn.innerHTML = CHECK_SVG_WHITE;
|
confirmBtn.innerHTML = CHECK_SVG_WHITE;
|
||||||
@@ -330,11 +479,11 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
cancelBtn.className = "bsplus-folder-input-cancel";
|
cancelBtn.className = "bsplus-folder-input-cancel";
|
||||||
cancelBtn.innerHTML = CLOSE_SVG;
|
cancelBtn.innerHTML = CLOSE_SVG;
|
||||||
|
|
||||||
|
row.appendChild(iconBtn);
|
||||||
row.appendChild(input);
|
row.appendChild(input);
|
||||||
row.appendChild(confirmBtn);
|
row.appendChild(confirmBtn);
|
||||||
row.appendChild(cancelBtn);
|
row.appendChild(cancelBtn);
|
||||||
|
|
||||||
// Color picker
|
|
||||||
const colorRow = document.createElement("div");
|
const colorRow = document.createElement("div");
|
||||||
colorRow.className = "bsplus-folder-colors";
|
colorRow.className = "bsplus-folder-colors";
|
||||||
for (const color of FOLDER_COLORS) {
|
for (const color of FOLDER_COLORS) {
|
||||||
@@ -354,14 +503,13 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
if (editFolder) {
|
if (editFolder) {
|
||||||
const folders = getFolders().map((f) =>
|
const folders = getFolders().map((f) =>
|
||||||
f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
|
f.id === editFolder.id ? { ...f, name, color: selectedColor, emoji: selectedIcon } : f,
|
||||||
);
|
);
|
||||||
saveFolders(folders);
|
saveFolders(folders);
|
||||||
} else {
|
} else {
|
||||||
const folder: Folder = { id: generateId(), name, color: selectedColor };
|
const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon };
|
||||||
saveFolders([...getFolders(), folder]);
|
saveFolders([...getFolders(), folder]);
|
||||||
}
|
}
|
||||||
applyBadges();
|
applyBadges();
|
||||||
@@ -386,23 +534,38 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
requestAnimationFrame(() => input.focus());
|
requestAnimationFrame(() => input.focus());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showIconPicker = (container: Element, onSelect: (iconSvg: string) => void) => {
|
||||||
|
const existing = container.querySelector(".bsplus-icon-picker");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const picker = document.createElement("div");
|
||||||
|
picker.className = "bsplus-icon-picker";
|
||||||
|
for (const icon of FOLDER_HEROICONS) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "bsplus-icon-opt";
|
||||||
|
btn.innerHTML = icon;
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(icon);
|
||||||
|
picker.remove();
|
||||||
|
});
|
||||||
|
picker.appendChild(btn);
|
||||||
|
}
|
||||||
|
container.appendChild(picker);
|
||||||
|
};
|
||||||
|
|
||||||
const showEditFolderInput = (container: Element, folder: Folder) => {
|
const showEditFolderInput = (container: Element, folder: Folder) => {
|
||||||
showNewFolderInput(container, folder);
|
showNewFolderInput(container, folder);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Intercept native sidebar clicks to clear folder filter ──
|
|
||||||
|
|
||||||
const attachNativeSidebarListeners = () => {
|
const attachNativeSidebarListeners = () => {
|
||||||
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
if (!sidebar) return;
|
if (!sidebar) return;
|
||||||
|
|
||||||
const ol = sidebar.querySelector("ol");
|
const ol = sidebar.querySelector("ol");
|
||||||
if (!ol) return;
|
if (!ol) return;
|
||||||
|
|
||||||
ol.addEventListener("click", (e) => {
|
ol.addEventListener("click", (e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest(".bsplus-folders-section")) return;
|
if (target.closest(".bsplus-folders-section")) return;
|
||||||
|
|
||||||
const li = target.closest("li");
|
const li = target.closest("li");
|
||||||
if (li && ol.contains(li)) {
|
if (li && ol.contains(li)) {
|
||||||
if (activeFolderId !== null) {
|
if (activeFolderId !== null) {
|
||||||
@@ -415,47 +578,22 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── "Add to folder" button in message action bar ──
|
const closeDropdown = () => {
|
||||||
|
if (openDropdown) {
|
||||||
const injectFolderButton = (actionsBar: Element) => {
|
openDropdown.remove();
|
||||||
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
|
openDropdown = null;
|
||||||
|
}
|
||||||
const wrapper = document.createElement("div");
|
if (dropdownCloseHandler) {
|
||||||
wrapper.className = "bsplus-folder-btn";
|
document.removeEventListener("click", dropdownCloseHandler, true);
|
||||||
wrapper.style.position = "relative";
|
dropdownCloseHandler = null;
|
||||||
wrapper.style.display = "inline-block";
|
|
||||||
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
|
|
||||||
btn.className = btnClasses;
|
|
||||||
btn.title = "Add to folder";
|
|
||||||
btn.innerHTML = FOLDER_ICON_SVG;
|
|
||||||
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
closeDropdown();
|
|
||||||
|
|
||||||
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
|
|
||||||
const messageId = selectedMsg?.getAttribute("data-message");
|
|
||||||
if (!messageId) return;
|
|
||||||
|
|
||||||
showFolderDropdown(wrapper, messageId);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.appendChild(btn);
|
|
||||||
|
|
||||||
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
|
|
||||||
if (moreMenu) {
|
|
||||||
actionsBar.insertBefore(wrapper, moreMenu);
|
|
||||||
} else {
|
|
||||||
actionsBar.appendChild(wrapper);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
|
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
|
||||||
|
closeDropdown();
|
||||||
const dropdown = document.createElement("div");
|
const dropdown = document.createElement("div");
|
||||||
dropdown.className = "bsplus-folder-dropdown";
|
dropdown.className = "bsplus-folder-dropdown";
|
||||||
|
dropdown.dataset.msgId = messageId;
|
||||||
|
|
||||||
const folders = getFolders();
|
const folders = getFolders();
|
||||||
const currentFolderIds = getMessageFolderIds(messageId);
|
const currentFolderIds = getMessageFolderIds(messageId);
|
||||||
@@ -470,6 +608,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
const isChecked = currentFolderIds.includes(folder.id);
|
const isChecked = currentFolderIds.includes(folder.id);
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
|
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
|
||||||
|
item.dataset.folderId = folder.id;
|
||||||
|
|
||||||
const check = document.createElement("div");
|
const check = document.createElement("div");
|
||||||
check.className = "bsplus-folder-dropdown-check";
|
check.className = "bsplus-folder-dropdown-check";
|
||||||
@@ -481,22 +620,26 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
dot.className = "bsplus-folder-dot";
|
dot.className = "bsplus-folder-dot";
|
||||||
dot.style.background = folder.color;
|
dot.style.background = folder.color;
|
||||||
|
|
||||||
|
const iconSpan = document.createElement("span");
|
||||||
|
iconSpan.className = "bsplus-folder-icon";
|
||||||
|
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||||
|
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.textContent = folder.name;
|
name.textContent = folder.name;
|
||||||
|
|
||||||
item.appendChild(check);
|
item.appendChild(check);
|
||||||
item.appendChild(dot);
|
item.appendChild(dot);
|
||||||
|
item.appendChild(iconSpan);
|
||||||
item.appendChild(name);
|
item.appendChild(name);
|
||||||
|
|
||||||
item.addEventListener("click", (e) => {
|
item.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
toggleMessageInFolder(messageId, folder.id);
|
toggleMessageInFolder(messageId, folder.id);
|
||||||
|
|
||||||
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
|
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
|
||||||
item.classList.toggle("bsplus-checked", nowChecked);
|
item.classList.toggle("bsplus-checked", nowChecked);
|
||||||
check.style.borderColor = nowChecked ? folder.color : "";
|
check.style.borderColor = nowChecked ? folder.color : "";
|
||||||
check.style.background = nowChecked ? folder.color : "";
|
check.style.background = nowChecked ? folder.color : "";
|
||||||
|
|
||||||
applyBadges();
|
applyBadges();
|
||||||
applyFolderFilter();
|
applyFolderFilter();
|
||||||
renderSidebarFolders();
|
renderSidebarFolders();
|
||||||
@@ -519,22 +662,105 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDropdown = () => {
|
const injectFolderButton = (actionsBar: Element) => {
|
||||||
if (openDropdown) {
|
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
|
||||||
openDropdown.remove();
|
const wrapper = document.createElement("div");
|
||||||
openDropdown = null;
|
wrapper.className = "bsplus-folder-btn";
|
||||||
}
|
wrapper.style.position = "relative";
|
||||||
if (dropdownCloseHandler) {
|
wrapper.style.display = "inline-block";
|
||||||
document.removeEventListener("click", dropdownCloseHandler, true);
|
const btn = document.createElement("button");
|
||||||
dropdownCloseHandler = null;
|
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
|
||||||
|
btn.className = btnClasses;
|
||||||
|
btn.title = "Add to folder";
|
||||||
|
btn.innerHTML = FOLDER_ICON_SVG;
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
|
||||||
|
const messageId = selectedMsg?.getAttribute("data-message");
|
||||||
|
if (!messageId) return;
|
||||||
|
showFolderDropdown(wrapper, messageId);
|
||||||
|
});
|
||||||
|
wrapper.appendChild(btn);
|
||||||
|
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
|
||||||
|
if (moreMenu) {
|
||||||
|
actionsBar.insertBefore(wrapper, moreMenu);
|
||||||
|
} else {
|
||||||
|
actionsBar.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Message badges ──
|
const showContextMenu = (e: MouseEvent, messageId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeDropdown();
|
||||||
|
const existing = document.querySelector(".bsplus-context-menu");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.className = "bsplus-context-menu";
|
||||||
|
menu.style.left = `${e.clientX}px`;
|
||||||
|
menu.style.top = `${e.clientY}px`;
|
||||||
|
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "bsplus-context-title";
|
||||||
|
title.textContent = "Add to folder";
|
||||||
|
menu.appendChild(title);
|
||||||
|
|
||||||
|
const folders = getFolders();
|
||||||
|
const currentFolderIds = getMessageFolderIds(messageId);
|
||||||
|
|
||||||
|
if (folders.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "bsplus-context-empty";
|
||||||
|
empty.textContent = "No folders";
|
||||||
|
menu.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
for (const folder of folders) {
|
||||||
|
const isChecked = currentFolderIds.includes(folder.id);
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.className = `bsplus-context-item${isChecked ? " bsplus-context-checked" : ""}`;
|
||||||
|
const dot = document.createElement("div");
|
||||||
|
dot.className = "bsplus-folder-dot";
|
||||||
|
dot.style.background = folder.color;
|
||||||
|
const iconSpan = document.createElement("span");
|
||||||
|
iconSpan.className = "bsplus-folder-icon";
|
||||||
|
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = folder.name;
|
||||||
|
item.appendChild(dot);
|
||||||
|
item.appendChild(iconSpan);
|
||||||
|
item.appendChild(name);
|
||||||
|
if (isChecked) {
|
||||||
|
const check = document.createElement("span");
|
||||||
|
check.className = "bsplus-context-checkmark";
|
||||||
|
check.textContent = "\u2713";
|
||||||
|
item.appendChild(check);
|
||||||
|
}
|
||||||
|
item.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMessageInFolder(messageId, folder.id);
|
||||||
|
applyBadges();
|
||||||
|
applyFolderFilter();
|
||||||
|
renderSidebarFolders();
|
||||||
|
menu.remove();
|
||||||
|
});
|
||||||
|
menu.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
const closeMenu = (ev: MouseEvent) => {
|
||||||
|
if (!menu.contains(ev.target as Node)) {
|
||||||
|
menu.remove();
|
||||||
|
document.removeEventListener("click", closeMenu);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener("click", closeMenu), 0);
|
||||||
|
};
|
||||||
|
|
||||||
const applyBadges = () => {
|
const applyBadges = () => {
|
||||||
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
||||||
|
|
||||||
if (!shouldShowBadgesInList()) {
|
if (!shouldShowBadgesInList()) {
|
||||||
for (const li of messageItems) {
|
for (const li of messageItems) {
|
||||||
const subject = li.querySelector("[class*='MessageList__subject___']");
|
const subject = li.querySelector("[class*='MessageList__subject___']");
|
||||||
@@ -546,26 +772,20 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = getFolders();
|
const folders = getFolders();
|
||||||
const assignments = getAssignments();
|
const assignments = getAssignments();
|
||||||
|
|
||||||
for (const li of messageItems) {
|
for (const li of messageItems) {
|
||||||
const msgId = li.getAttribute("data-message");
|
const msgId = li.getAttribute("data-message");
|
||||||
if (!msgId) continue;
|
if (!msgId) continue;
|
||||||
|
|
||||||
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
|
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
|
||||||
|
const folderIds: string[] = [];
|
||||||
const folderIds = [];
|
|
||||||
for (const [fId, mIds] of Object.entries(assignments)) {
|
for (const [fId, mIds] of Object.entries(assignments)) {
|
||||||
if (mIds.includes(msgId)) folderIds.push(fId);
|
if (mIds.includes(msgId)) folderIds.push(fId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folderIds.length === 0) {
|
if (folderIds.length === 0) {
|
||||||
badgeContainer?.remove();
|
badgeContainer?.remove();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!badgeContainer) {
|
if (!badgeContainer) {
|
||||||
badgeContainer = document.createElement("div");
|
badgeContainer = document.createElement("div");
|
||||||
badgeContainer.className = "bsplus-msg-badges";
|
badgeContainer.className = "bsplus-msg-badges";
|
||||||
@@ -583,7 +803,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
li.appendChild(badgeContainer);
|
li.appendChild(badgeContainer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
badgeContainer.innerHTML = "";
|
badgeContainer.innerHTML = "";
|
||||||
for (const fId of folderIds) {
|
for (const fId of folderIds) {
|
||||||
const folder = folders.find((f) => f.id === fId);
|
const folder = folders.find((f) => f.id === fId);
|
||||||
@@ -591,7 +810,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "bsplus-msg-badge";
|
badge.className = "bsplus-msg-badge";
|
||||||
badge.style.background = folder.color;
|
badge.style.background = folder.color;
|
||||||
badge.textContent = folder.name;
|
badge.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`;
|
||||||
badge.title = `Filter by "${folder.name}"`;
|
badge.title = `Filter by "${folder.name}"`;
|
||||||
badge.addEventListener("click", (e) => {
|
badge.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -605,12 +824,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Folder filtering ──
|
|
||||||
|
|
||||||
const applyFolderFilter = () => {
|
const applyFolderFilter = () => {
|
||||||
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
||||||
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
|
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
|
||||||
|
|
||||||
if (activeFolderId === null) {
|
if (activeFolderId === null) {
|
||||||
if (api.settings.hideFolderedMessagesInAll) {
|
if (api.settings.hideFolderedMessagesInAll) {
|
||||||
for (const li of messageItems) {
|
for (const li of messageItems) {
|
||||||
@@ -629,9 +845,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
|
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
|
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
|
||||||
|
|
||||||
for (const li of messageItems) {
|
for (const li of messageItems) {
|
||||||
const msgId = li.getAttribute("data-message");
|
const msgId = li.getAttribute("data-message");
|
||||||
if (msgId && folderMsgIds.includes(msgId)) {
|
if (msgId && folderMsgIds.includes(msgId)) {
|
||||||
@@ -643,25 +857,35 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
|
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Observers ──
|
|
||||||
|
|
||||||
const setupMessageListObserver = () => {
|
const setupMessageListObserver = () => {
|
||||||
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
|
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
|
||||||
if (!messageList || messageListObserver) return;
|
if (!messageList || messageListObserver) return;
|
||||||
|
|
||||||
messageListObserver = new MutationObserver(() => {
|
messageListObserver = new MutationObserver(() => {
|
||||||
applyBadges();
|
applyBadges();
|
||||||
applyFolderFilter();
|
applyFolderFilter();
|
||||||
|
attachDragListeners();
|
||||||
|
attachContextMenuListeners();
|
||||||
});
|
});
|
||||||
messageListObserver.observe(messageList, { childList: true, subtree: false });
|
messageListObserver.observe(messageList, { childList: true, subtree: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const attachContextMenuListeners = () => {
|
||||||
|
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
|
||||||
|
if (li.getAttribute("data-bsplus-ctx") === "true") return;
|
||||||
|
li.setAttribute("data-bsplus-ctx", "true");
|
||||||
|
li.addEventListener("contextmenu", (e) => {
|
||||||
|
const msgId = li.getAttribute("data-message");
|
||||||
|
if (msgId) {
|
||||||
|
showContextMenu(e, msgId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const setupActionsObserver = () => {
|
const setupActionsObserver = () => {
|
||||||
if (actionsObserver) return;
|
if (actionsObserver) return;
|
||||||
|
|
||||||
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
|
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
actionsObserver = new MutationObserver(() => {
|
actionsObserver = new MutationObserver(() => {
|
||||||
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
||||||
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
|
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
|
||||||
@@ -671,28 +895,19 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
actionsObserver.observe(target, { childList: true, subtree: true });
|
actionsObserver.observe(target, { childList: true, subtree: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Main page handler ──
|
|
||||||
|
|
||||||
const handleMessagesPage = async () => {
|
const handleMessagesPage = async () => {
|
||||||
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
|
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
|
||||||
|
|
||||||
renderSidebarFolders();
|
renderSidebarFolders();
|
||||||
attachNativeSidebarListeners();
|
attachNativeSidebarListeners();
|
||||||
|
|
||||||
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
|
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
|
||||||
applyBadges();
|
applyBadges();
|
||||||
applyFolderFilter();
|
applyFolderFilter();
|
||||||
setupMessageListObserver();
|
setupMessageListObserver();
|
||||||
|
|
||||||
// The actions bar only exists when a message is selected/open,
|
|
||||||
// so we observe the whole viewer for it to appear dynamically
|
|
||||||
setupActionsObserver();
|
setupActionsObserver();
|
||||||
|
attachDragListeners();
|
||||||
// If a message is already selected, inject immediately
|
attachContextMenuListeners();
|
||||||
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
||||||
if (actionsBar) injectFolderButton(actionsBar);
|
if (actionsBar) injectFolderButton(actionsBar);
|
||||||
|
|
||||||
// Re-observe the sidebar for SEQTA re-renders
|
|
||||||
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
if (sidebar && !sidebarObserver) {
|
if (sidebar && !sidebarObserver) {
|
||||||
sidebarObserver = new MutationObserver(() => {
|
sidebarObserver = new MutationObserver(() => {
|
||||||
@@ -706,11 +921,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Lifecycle ──
|
|
||||||
|
|
||||||
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
|
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
|
||||||
unregisters.push(mountUnsub);
|
unregisters.push(mountUnsub);
|
||||||
|
|
||||||
unregisters.push(
|
unregisters.push(
|
||||||
api.settings.onChange("showTagsInAllMessages", () => {
|
api.settings.onChange("showTagsInAllMessages", () => {
|
||||||
applyBadges();
|
applyBadges();
|
||||||
@@ -732,6 +944,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
|
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
|
||||||
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
|
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
|
||||||
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
|
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
|
||||||
|
document.querySelectorAll(".bsplus-context-menu").forEach((el) => el.remove());
|
||||||
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
|
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
|
||||||
if (subject.querySelector(".bsplus-subject-text")) {
|
if (subject.querySelector(".bsplus-subject-text")) {
|
||||||
restoreSubjectPlain(subject);
|
restoreSubjectPlain(subject);
|
||||||
@@ -741,6 +954,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
|||||||
el.classList.remove("bsplus-folder-hidden"),
|
el.classList.remove("bsplus-folder-hidden"),
|
||||||
);
|
);
|
||||||
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
|
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
|
||||||
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,21 @@
|
|||||||
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
|
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-item,
|
||||||
|
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-input,
|
||||||
|
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-colors,
|
||||||
|
.bsplus-folders-section.bsplus-section-folded .bsplus-emoji-picker,
|
||||||
|
.bsplus-folders-section.bsplus-section-folded .bsplus-all-msgs {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-folders-header {
|
.bsplus-folders-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 4px;
|
||||||
padding: 6px 12px 2px;
|
padding: 6px 12px 2px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -20,6 +29,33 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: var(--text-primary, #666);
|
color: var(--text-primary, #666);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-collapse {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 18px !important;
|
||||||
|
height: 18px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-collapse:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-collapse.bsplus-folded svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-folders-add-btn {
|
.bsplus-folders-add-btn {
|
||||||
@@ -51,12 +87,21 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease, opacity 0.2s;
|
||||||
position: relative;
|
position: relative;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item.bsplus-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item.bsplus-drag-over {
|
||||||
|
background: var(--better-main, #007bff22) !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.bsplus-folder-item:hover {
|
.bsplus-folder-item:hover {
|
||||||
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||||
}
|
}
|
||||||
@@ -76,6 +121,18 @@
|
|||||||
border-radius: 0 2px 2px 0;
|
border-radius: 0 2px 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-drag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s;
|
||||||
|
margin-right: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item:hover .bsplus-folder-drag {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.bsplus-folder-dot {
|
.bsplus-folder-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -83,6 +140,23 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
.bsplus-folder-name {
|
.bsplus-folder-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary, #333);
|
color: var(--text-primary, #333);
|
||||||
@@ -97,6 +171,8 @@
|
|||||||
color: var(--text-primary, #999);
|
color: var(--text-primary, #999);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bsplus-folder-actions {
|
.bsplus-folder-actions {
|
||||||
@@ -158,6 +234,35 @@
|
|||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-icon-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: 1px solid var(--background-secondary, #ccc) !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
background: var(--background-secondary, #f5f5f5) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transition: all .15s;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-icon-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-icon-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
.bsplus-folder-input-confirm,
|
.bsplus-folder-input-confirm,
|
||||||
.bsplus-folder-input-cancel {
|
.bsplus-folder-input-cancel {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
@@ -192,6 +297,43 @@
|
|||||||
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Icon picker ── */
|
||||||
|
.bsplus-icon-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px 6px;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-icon-opt {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
background: transparent !important;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 !important;
|
||||||
|
transition: all .15s;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-icon-opt svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-icon-opt:hover {
|
||||||
|
transform: scale(1.3);
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Color picker row ── */
|
/* ── Color picker row ── */
|
||||||
.bsplus-folder-colors {
|
.bsplus-folder-colors {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -322,14 +464,113 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Let primary column use available space instead of being clipped ── */
|
/* ── Context menu ── */
|
||||||
|
.bsplus-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--background-primary, #fff) !important;
|
||||||
|
border: 1px solid var(--background-secondary, #e0e0e0) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 2147483646;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: bsplus-dropdown-in 0.12s ease-out;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-title {
|
||||||
|
padding: 6px 12px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-primary, #999) !important;
|
||||||
|
opacity: 0.5;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-item:hover {
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-item span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-checkmark {
|
||||||
|
color: var(--better-main, #007bff) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
flex: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-item {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 12px !important;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left !important;
|
||||||
|
color: var(--text-primary, #333) !important;
|
||||||
|
transition: background .1s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-item .bsplus-folder-icon {
|
||||||
|
color: var(--text-primary, #333) !important;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-item .bsplus-folder-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-item:hover {
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-item span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-checkmark {
|
||||||
|
color: var(--better-main, #007bff) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
flex: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-context-empty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary, #999);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag feedback ── */
|
||||||
|
.bsplus-msg-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*='MessageList__MessageList___'] ol > li[data-message] {
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout fixes ── */
|
||||||
[class*='MessageList__primary___'] {
|
[class*='MessageList__primary___'] {
|
||||||
flex: 1 1 0% !important;
|
flex: 1 1 0% !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Make subject line a flex row so badges sit inline ── */
|
|
||||||
[class*='MessageList__subject___'] {
|
[class*='MessageList__subject___'] {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -338,7 +579,6 @@
|
|||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Subject text truncates to make room for badges ── */
|
|
||||||
.bsplus-subject-text {
|
.bsplus-subject-text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -347,7 +587,6 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shrink the secondary column to its content ── */
|
|
||||||
[class*='MessageList__secondary___'] {
|
[class*='MessageList__secondary___'] {
|
||||||
flex: 0 0 auto !important;
|
flex: 0 0 auto !important;
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
@@ -355,7 +594,6 @@
|
|||||||
max-width: 200px !important;
|
max-width: 200px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Constrain the flags/attachment icon column ── */
|
|
||||||
[class*='MessageList__flags___'] {
|
[class*='MessageList__flags___'] {
|
||||||
width: 24px !important;
|
width: 24px !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
@@ -391,7 +629,7 @@
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder filtering (hide messages not in active folder) ── */
|
/* ── Folder filtering ── */
|
||||||
.bsplus-folder-hidden {
|
.bsplus-folder-hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -489,3 +727,5 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
|
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
|||||||
import debounce from "@/seqta/utils/debounce";
|
import debounce from "@/seqta/utils/debounce";
|
||||||
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import { getApiBase } from "@/seqta/utils/DevApiBase";
|
||||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||||
import {
|
import {
|
||||||
clearCustomThemeAdaptiveCssVariables,
|
clearCustomThemeAdaptiveCssVariables,
|
||||||
@@ -545,7 +546,10 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly THEME_API_BASE = 'https://betterseqta.org/api';
|
/** Use a getter so dev-mode session-only base URL overrides take effect immediately. */
|
||||||
|
private get THEME_API_BASE(): string {
|
||||||
|
return `${getApiBase()}/api`;
|
||||||
|
}
|
||||||
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
|
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -145,8 +145,10 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
|
|
||||||
let observer: MutationObserver | null = null;
|
let observer: MutationObserver | null = null;
|
||||||
let quickbarObserver: MutationObserver | null = null;
|
let quickbarObserver: MutationObserver | null = null;
|
||||||
|
let quickbarSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastClickedCi: number | null = null;
|
let lastClickedCi: number | null = null;
|
||||||
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
|
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
|
||||||
|
let lastSyncedQuickbarCi: number | null = null;
|
||||||
|
|
||||||
const getOverrides = (): TimetableOverrides =>
|
const getOverrides = (): TimetableOverrides =>
|
||||||
api.storage.timetableOverrides ?? {};
|
api.storage.timetableOverrides ?? {};
|
||||||
@@ -186,9 +188,11 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
||||||
}
|
}
|
||||||
|
|
||||||
const captureClick = (e: MouseEvent) => {
|
const captureClick = () => {
|
||||||
lastClickedCi = ci;
|
lastClickedCi = ci;
|
||||||
lastClickedEntry = { roomEl, teacherEl, item };
|
lastClickedEntry = { roomEl, teacherEl, item };
|
||||||
|
lastSyncedQuickbarCi = null;
|
||||||
|
scheduleQuickbarSync();
|
||||||
};
|
};
|
||||||
entry.addEventListener("click", captureClick, true);
|
entry.addEventListener("click", captureClick, true);
|
||||||
};
|
};
|
||||||
@@ -199,6 +203,76 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getVisibleClassQuickbar = (): HTMLElement | null => {
|
||||||
|
const quickbar = document.querySelector(
|
||||||
|
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible",
|
||||||
|
);
|
||||||
|
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
|
||||||
|
return quickbar as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyOverridesToQuickbar = (quickbar: HTMLElement): void => {
|
||||||
|
if (lastClickedCi === null) return;
|
||||||
|
if (lastSyncedQuickbarCi === lastClickedCi) return;
|
||||||
|
|
||||||
|
const description =
|
||||||
|
quickbar.querySelector(".title")?.textContent?.trim() ??
|
||||||
|
lastClickedEntry?.item.description ??
|
||||||
|
"";
|
||||||
|
const override = getEffectiveOverride(lastClickedCi, description);
|
||||||
|
if (!override) {
|
||||||
|
lastSyncedQuickbarCi = lastClickedCi;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomEl = quickbar.querySelector(".meta .room");
|
||||||
|
const teacherEl = quickbar.querySelector(".meta .teacher");
|
||||||
|
if (override.room !== undefined && !roomEl) return;
|
||||||
|
if (override.staff !== undefined && !teacherEl) return;
|
||||||
|
|
||||||
|
if (override.room !== undefined && roomEl && roomEl.textContent !== override.room) {
|
||||||
|
roomEl.textContent = override.room;
|
||||||
|
}
|
||||||
|
if (override.staff !== undefined && teacherEl && teacherEl.textContent !== override.staff) {
|
||||||
|
teacherEl.textContent = override.staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSyncedQuickbarCi = lastClickedCi;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVisibleQuickbar = (room: string, staff: string): void => {
|
||||||
|
const quickbar = getVisibleClassQuickbar();
|
||||||
|
if (!quickbar) return;
|
||||||
|
const roomEl = quickbar.querySelector(".meta .room");
|
||||||
|
const teacherEl = quickbar.querySelector(".meta .teacher");
|
||||||
|
if (roomEl && roomEl.textContent !== room) roomEl.textContent = room;
|
||||||
|
if (teacherEl && teacherEl.textContent !== staff) teacherEl.textContent = staff;
|
||||||
|
if (lastClickedCi !== null) lastSyncedQuickbarCi = lastClickedCi;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncClassQuickbar = (quickbar: HTMLElement): void => {
|
||||||
|
applyOverridesToQuickbar(quickbar);
|
||||||
|
addEditButtonToQuickbar(quickbar);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleQuickbarSync = (): void => {
|
||||||
|
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const trySync = (): void => {
|
||||||
|
const quickbar = getVisibleClassQuickbar();
|
||||||
|
if (quickbar && lastClickedCi !== null) {
|
||||||
|
syncClassQuickbar(quickbar);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (++attempts < 6) {
|
||||||
|
quickbarSyncTimer = setTimeout(trySync, 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(trySync);
|
||||||
|
};
|
||||||
|
|
||||||
const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
|
const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
|
||||||
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
|
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
|
||||||
|
|
||||||
@@ -251,6 +325,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
}
|
}
|
||||||
if (entryData.roomEl) entryData.roomEl.textContent = room;
|
if (entryData.roomEl) entryData.roomEl.textContent = room;
|
||||||
if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
|
if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
|
||||||
|
updateVisibleQuickbar(room, staff);
|
||||||
processAllEntries();
|
processAllEntries();
|
||||||
},
|
},
|
||||||
(ci) => {
|
(ci) => {
|
||||||
@@ -262,6 +337,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
api.storage.timetableOverridesBySubject = bySubject;
|
api.storage.timetableOverridesBySubject = bySubject;
|
||||||
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
|
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
|
||||||
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
|
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
|
||||||
|
updateVisibleQuickbar(item.room, item.staff);
|
||||||
processAllEntries();
|
processAllEntries();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -271,34 +347,30 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const syncQuickbarFromDOM = () => {
|
const syncQuickbarFromDOM = () => {
|
||||||
const quickbar = document.querySelector(
|
const quickbar = getVisibleClassQuickbar();
|
||||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
|
if (!quickbar || lastClickedCi === null || !lastClickedEntry) return;
|
||||||
);
|
syncClassQuickbar(quickbar);
|
||||||
if (quickbar && quickbar.getAttribute("data-type") === "class") {
|
|
||||||
const titleEl = quickbar.querySelector(".title");
|
|
||||||
const roomEl = quickbar.querySelector(".meta .room");
|
|
||||||
const teacherEl = quickbar.querySelector(".meta .teacher");
|
|
||||||
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
|
|
||||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setupQuickbarObserver = () => {
|
const setupQuickbarObserver = () => {
|
||||||
const timetablePage = document.querySelector(".timetablepage");
|
const timetablePage = document.querySelector(".timetablepage");
|
||||||
if (!timetablePage || quickbarObserver) return;
|
if (!timetablePage || quickbarObserver) return;
|
||||||
|
|
||||||
quickbarObserver = new MutationObserver(() => {
|
quickbarObserver = new MutationObserver((mutations) => {
|
||||||
const quickbar = document.querySelector(
|
const quickbarBecameVisible = mutations.some(
|
||||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
|
(mutation) =>
|
||||||
|
mutation.type === "attributes" &&
|
||||||
|
mutation.attributeName === "class" &&
|
||||||
|
(mutation.target as HTMLElement).classList.contains("quickbar") &&
|
||||||
|
(mutation.target as HTMLElement).classList.contains("visible"),
|
||||||
);
|
);
|
||||||
if (quickbar?.getAttribute("data-type") === "class") {
|
if (!quickbarBecameVisible || lastClickedCi === null) return;
|
||||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
|
||||||
}
|
const quickbar = getVisibleClassQuickbar();
|
||||||
|
if (quickbar) syncClassQuickbar(quickbar);
|
||||||
});
|
});
|
||||||
|
|
||||||
quickbarObserver.observe(timetablePage, {
|
quickbarObserver.observe(timetablePage, {
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
subtree: true,
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ["class"],
|
attributeFilter: ["class"],
|
||||||
@@ -336,6 +408,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
|||||||
unregister();
|
unregister();
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
quickbarObserver?.disconnect();
|
quickbarObserver?.disconnect();
|
||||||
|
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
|
||||||
styleEl.remove();
|
styleEl.remove();
|
||||||
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
|
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
|
||||||
el.removeAttribute("data-timetable-edit-processed");
|
el.removeAttribute("data-timetable-edit-processed");
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
|
|||||||
import profilePicturePlugin from "./built-in/profilePicture";
|
import profilePicturePlugin from "./built-in/profilePicture";
|
||||||
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||||
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||||
//import messageFoldersPlugin from "./built-in/messageFolders";
|
import messageFoldersPlugin from "./built-in/messageFolders";
|
||||||
//import testPlugin from './built-in/test';
|
//import testPlugin from './built-in/test';
|
||||||
|
|
||||||
// Heavy plugins (lazy-loaded only when enabled)
|
// Heavy plugins (lazy-loaded only when enabled)
|
||||||
@@ -29,7 +29,7 @@ pluginManager.registerPlugin(timetableEditPlugin);
|
|||||||
pluginManager.registerPlugin(profilePicturePlugin);
|
pluginManager.registerPlugin(profilePicturePlugin);
|
||||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||||
//pluginManager.registerPlugin(messageFoldersPlugin);
|
pluginManager.registerPlugin(messageFoldersPlugin);
|
||||||
//pluginManager.registerPlugin(testPlugin);
|
//pluginManager.registerPlugin(testPlugin);
|
||||||
|
|
||||||
// Register heavy plugins with lazy loading
|
// Register heavy plugins with lazy loading
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export async function finishLoad() {
|
|||||||
console.error("Error during loading cleanup:", err);
|
console.error("Error during loading cleanup:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
runStartupPopupQueue();
|
void runStartupPopupQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetCSSElement(file: string) {
|
export function GetCSSElement(file: string) {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
|
const DEFAULT_BASE = "https://betterseqta.org";
|
||||||
|
const KEY = "bsplus_dev_api_base";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current content-API base URL.
|
||||||
|
*
|
||||||
|
* Reads from `sessionStorage` so a developer can temporarily override the
|
||||||
|
* server for testing. The value is cleared when the browser session ends,
|
||||||
|
* leaving production traffic unaffected for normal users.
|
||||||
|
*/
|
||||||
|
export function getApiBase(): string {
|
||||||
|
try {
|
||||||
|
if (typeof sessionStorage === "undefined") return DEFAULT_BASE;
|
||||||
|
const v = sessionStorage.getItem(KEY);
|
||||||
|
if (v && /^https?:\/\//.test(v)) return v.replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may throw in some restricted contexts; fall back silently.
|
||||||
|
}
|
||||||
|
return DEFAULT_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a session-scoped override and broadcast it to the background script
|
||||||
|
* so its `fetch` calls hit the same host.
|
||||||
|
*
|
||||||
|
* Pass `null` to clear the override.
|
||||||
|
*/
|
||||||
|
export function setApiBase(url: string | null): void {
|
||||||
|
try {
|
||||||
|
if (!url) {
|
||||||
|
sessionStorage.removeItem(KEY);
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem(KEY, url.replace(/\/$/, ""));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
void browser.runtime
|
||||||
|
.sendMessage({ type: "setDevApiBase", url: url || null })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the override URL if one is currently set in this session. */
|
||||||
|
export function getStoredOverride(): string | null {
|
||||||
|
try {
|
||||||
|
if (typeof sessionStorage === "undefined") return null;
|
||||||
|
return sessionStorage.getItem(KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the current session override to the background script.
|
||||||
|
* Call this early in page load so the background context stays in sync after
|
||||||
|
* service-worker restarts.
|
||||||
|
*/
|
||||||
|
export function syncApiBaseToBackground(): void {
|
||||||
|
const override = getStoredOverride();
|
||||||
|
void browser.runtime
|
||||||
|
.sendMessage({ type: "setDevApiBase", url: override })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
@@ -113,16 +113,7 @@ export async function loadHomePage() {
|
|||||||
callHomeTimetable(TodayFormatted, true);
|
callHomeTimetable(TodayFormatted, true);
|
||||||
|
|
||||||
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
|
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
|
||||||
const activeYear = activeClass?.year;
|
const activeSubjects = activeClass?.subjects || [];
|
||||||
const allSubjectsInYear = classes
|
|
||||||
.filter((c: any) => c.year === activeYear)
|
|
||||||
.flatMap((c: any) => c.subjects || []);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const activeSubjects = allSubjectsInYear.filter((s: any) => {
|
|
||||||
if (seen.has(s.code)) return false;
|
|
||||||
seen.add(s.code);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
|
const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
|
||||||
const currentAssessments = assessments
|
const currentAssessments = assessments
|
||||||
.filter((a: any) => activeSubjectCodes.includes(a.code))
|
.filter((a: any) => activeSubjectCodes.includes(a.code))
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ export function showEngageParentsToast() {
|
|||||||
settingsState.engageParentsAnnouncementShown = true;
|
settingsState.engageParentsAnnouncementShown = true;
|
||||||
|
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
toast.className = "bsplus-toast";
|
toast.className = "bsplus-toast engageParentsToast";
|
||||||
toast.innerHTML = /* html */ `
|
toast.innerHTML = /* html */ `
|
||||||
|
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
||||||
<div class="bsplus-toast-content">
|
<div class="bsplus-toast-content">
|
||||||
|
<p class="bsplus-toast-eyebrow">SEQTA Engage support</p>
|
||||||
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
|
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
|
||||||
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
|
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="bsplus-toast-close" aria-label="Dismiss">×</button>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
toast.style.opacity = "0";
|
toast.style.opacity = "0";
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import browser from "webextension-polyfill";
|
||||||
|
import stringToHTML from "../stringToHTML";
|
||||||
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
|
import { closePopup } from "./PopupManager";
|
||||||
|
import { getApiBase } from "../DevApiBase";
|
||||||
|
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
||||||
|
import { cloudAuth } from "../CloudAuth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server response shape from `/api/theme-of-the-month/current`.
|
||||||
|
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
|
||||||
|
*/
|
||||||
|
export interface ThemeOfTheMonthEntry {
|
||||||
|
id: string;
|
||||||
|
month: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
cover_image: string | null;
|
||||||
|
theme_id: string | null;
|
||||||
|
theme: { id: string; name: string; slug: string } | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current month's Theme of the Month entry from the API.
|
||||||
|
* Returns `null` when no entry is configured for this month, or when the
|
||||||
|
* request fails (we never want a network problem to block other startup
|
||||||
|
* popups).
|
||||||
|
*/
|
||||||
|
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return null;
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
if (!data || typeof data !== "object" || !data.id) return null;
|
||||||
|
return data as ThemeOfTheMonthEntry;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when we have a new monthly entry the user hasn't dismissed yet. */
|
||||||
|
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
|
||||||
|
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
||||||
|
return settingsState.themeOfTheMonthLastSeenId !== entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthLabel(month: string): string {
|
||||||
|
const [yyyy, mm] = month.split("-");
|
||||||
|
if (!yyyy || !mm) return month;
|
||||||
|
const date = new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, 1);
|
||||||
|
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same priority as the theme store: marquee, then cover/banner. */
|
||||||
|
function heroUrlFromStoreTheme(theme: {
|
||||||
|
marqueeImage?: string | null;
|
||||||
|
coverImage?: string | null;
|
||||||
|
}): string | null {
|
||||||
|
const url = (theme.marqueeImage || theme.coverImage || "").trim();
|
||||||
|
return url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads hero image for a store theme via the background script (same path as
|
||||||
|
* {@link ThemeSelector} / theme store detail fetches).
|
||||||
|
*/
|
||||||
|
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const token = await cloudAuth.getStoredToken();
|
||||||
|
const res = (await browser.runtime.sendMessage({
|
||||||
|
type: "fetchThemeDetails",
|
||||||
|
themeId,
|
||||||
|
token: token ?? undefined,
|
||||||
|
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
|
||||||
|
|
||||||
|
if (!res?.success || !res?.data?.theme) return null;
|
||||||
|
return heroUrlFromStoreTheme(res.data.theme);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linked theme store image, else optional admin-uploaded cover. */
|
||||||
|
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
|
||||||
|
const themeId = entry.theme_id ?? entry.theme?.id;
|
||||||
|
if (themeId) {
|
||||||
|
const fromStore = await fetchThemeStoreHeroImage(themeId);
|
||||||
|
if (fromStore) return fromStore;
|
||||||
|
}
|
||||||
|
const fallback = entry.cover_image?.trim();
|
||||||
|
return fallback || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeThemeOfTheMonthCard(
|
||||||
|
card: HTMLElement,
|
||||||
|
onDismissed?: () => void,
|
||||||
|
markSeen = true,
|
||||||
|
) {
|
||||||
|
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||||
|
|
||||||
|
if (markSeen) {
|
||||||
|
const entryId = card.dataset.entryId;
|
||||||
|
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.classList.add("themeOfTheMonthCardClosing");
|
||||||
|
window.setTimeout(() => {
|
||||||
|
card.remove();
|
||||||
|
onDismissed?.();
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the Theme of the Month announcement card.
|
||||||
|
*/
|
||||||
|
export async function OpenThemeOfTheMonthPopup(
|
||||||
|
entry: ThemeOfTheMonthEntry,
|
||||||
|
onDismissed?: () => void,
|
||||||
|
) {
|
||||||
|
document.getElementById("theme-of-the-month-card")?.remove();
|
||||||
|
|
||||||
|
const monthLabel = formatMonthLabel(entry.month);
|
||||||
|
const heroUrl = await resolvePopupHeroImageUrl(entry);
|
||||||
|
const description = escapeHTML(entry.description).replace(/\n/g, " ");
|
||||||
|
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
|
||||||
|
|
||||||
|
const card = stringToHTML(/* html */ `
|
||||||
|
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
|
||||||
|
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
|
||||||
|
${
|
||||||
|
heroUrl
|
||||||
|
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
<div class="themeOfTheMonthCardBody">
|
||||||
|
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||||
|
<h2>${escapeHTML(entry.title)}</h2>
|
||||||
|
<p class="themeOfTheMonthCardDescription">${description}</p>
|
||||||
|
<div class="themeOfTheMonthCardActions">
|
||||||
|
${
|
||||||
|
linkedThemeId
|
||||||
|
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
`).firstChild as HTMLElement;
|
||||||
|
|
||||||
|
card.dataset.entryId = entry.id;
|
||||||
|
const autoCloseTimeout = window.setTimeout(() => {
|
||||||
|
closeThemeOfTheMonthCard(card, onDismissed);
|
||||||
|
}, 12000);
|
||||||
|
|
||||||
|
const dismiss = (markSeen = true) => {
|
||||||
|
window.clearTimeout(autoCloseTimeout);
|
||||||
|
closeThemeOfTheMonthCard(card, onDismissed, markSeen);
|
||||||
|
};
|
||||||
|
|
||||||
|
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
|
||||||
|
|
||||||
|
card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => {
|
||||||
|
dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||||
|
dismiss();
|
||||||
|
openThemeStoreWithHighlight(linkedThemeId!);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
|
||||||
|
settingsState.themeOfTheMonthDisabled = true;
|
||||||
|
dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev helper: fetch the current month's entry and show the popup immediately,
|
||||||
|
* even if the user has already dismissed it this month.
|
||||||
|
*/
|
||||||
|
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
|
||||||
|
const entry = await fetchThemeOfTheMonth();
|
||||||
|
if (!entry) {
|
||||||
|
alert(
|
||||||
|
"No Theme of the Month entry for the current month (UTC). Create one in the website admin, or check your dev API base URL.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsState.themeOfTheMonthLastSeenId = undefined;
|
||||||
|
|
||||||
|
if (document.getElementById("whatsnewbk")) {
|
||||||
|
await closePopup();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
await OpenThemeOfTheMonthPopup(entry);
|
||||||
|
}
|
||||||
@@ -33,7 +33,14 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
|||||||
|
|
||||||
const text = stringToHTML(/* html */ `
|
const text = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||||
|
<h1>3.6.5 - Theme of the Month, custom message folders & assessment weighting overrides</h1>
|
||||||
|
<li>Added Theme of the Month — a monthly featured theme popup with a link to view it in the theme store.</li>
|
||||||
|
<li>Added custom message folders for organising direct DM's with drag to reorder.</li>
|
||||||
|
<li>Added the ability to override/add weightings to assessments (on assessment page).</li>
|
||||||
|
<li>Fixed custom room and teacher names not showing in the timetable popup.</li>
|
||||||
|
<li>Fixed assessment averages treating N/A weightings incorrectly in subject average calculations.</li>
|
||||||
|
<li>Fixed the display of weightings that could not automatically be discovered.</li>
|
||||||
|
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
|
||||||
|
|
||||||
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
|
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
|
||||||
<li>Added advanced colour adjustments variables for theme customisation.</li>
|
<li>Added advanced colour adjustments variables for theme customisation.</li>
|
||||||
|
|||||||
@@ -4,20 +4,40 @@ import {
|
|||||||
shouldShowEngageParentsAnnouncement,
|
shouldShowEngageParentsAnnouncement,
|
||||||
showEngageParentsToast,
|
showEngageParentsToast,
|
||||||
} from "./OpenEngageParentsAnnouncement";
|
} from "./OpenEngageParentsAnnouncement";
|
||||||
|
import {
|
||||||
|
fetchThemeOfTheMonth,
|
||||||
|
OpenThemeOfTheMonthPopup,
|
||||||
|
shouldShowThemeOfTheMonth,
|
||||||
|
} from "./OpenThemeOfTheMonthPopup";
|
||||||
|
import { syncApiBaseToBackground } from "../DevApiBase";
|
||||||
|
|
||||||
type QueueStep = (goNext: () => void) => void;
|
type QueueStep = (goNext: () => void) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs startup modals in order: What's New (if the extension just updated),
|
* Runs startup modals in order: What's New (if the extension just updated),
|
||||||
* then shows the SEQTA Engage toast (once, non-blocking).
|
* Theme of the Month (when a new monthly entry hasn't been seen), then shows
|
||||||
|
* the SEQTA Engage toast (once, non-blocking).
|
||||||
*/
|
*/
|
||||||
export function runStartupPopupQueue() {
|
export async function runStartupPopupQueue() {
|
||||||
|
// Make sure the background script knows about any dev-mode API override
|
||||||
|
// before we start firing requests.
|
||||||
|
syncApiBaseToBackground();
|
||||||
|
|
||||||
const steps: QueueStep[] = [];
|
const steps: QueueStep[] = [];
|
||||||
|
|
||||||
if (settingsState.justupdated) {
|
if (settingsState.justupdated) {
|
||||||
steps.push((goNext) => OpenWhatsNewPopup(goNext));
|
steps.push((goNext) => OpenWhatsNewPopup(goNext));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch the Theme of the Month before queueing so we don't show an empty
|
||||||
|
// popup if the network or server is unavailable.
|
||||||
|
const themeOfTheMonth = await fetchThemeOfTheMonth();
|
||||||
|
if (shouldShowThemeOfTheMonth(themeOfTheMonth)) {
|
||||||
|
steps.push((goNext) => {
|
||||||
|
void OpenThemeOfTheMonthPopup(themeOfTheMonth!, goNext);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function runNext() {
|
function runNext() {
|
||||||
const step = steps.shift();
|
const step = steps.shift();
|
||||||
if (step) step(runNext);
|
if (step) step(runNext);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { OpenStorePage } from "@/seqta/ui/renderStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level handoff for "open the theme store and highlight this theme".
|
||||||
|
*
|
||||||
|
* The store page is mounted lazily inside a Shadow DOM the first time it
|
||||||
|
* opens, so a `CustomEvent` listener would have to be wired up before mount
|
||||||
|
* (causing a race). Using a shared cell keeps the producer (popup button) and
|
||||||
|
* consumer (store `onMount`) decoupled without that timing constraint.
|
||||||
|
*
|
||||||
|
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
|
||||||
|
*/
|
||||||
|
let pendingHighlightThemeId: string | null = null;
|
||||||
|
|
||||||
|
/** Read and clear the pending theme id (called by the store on mount). */
|
||||||
|
export function consumePendingHighlightThemeId(): string | null {
|
||||||
|
const id = pendingHighlightThemeId;
|
||||||
|
pendingHighlightThemeId = null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the theme store and asks it to focus / highlight the given theme.
|
||||||
|
* If the store is already mounted we dispatch a DOM event so it can react
|
||||||
|
* without remounting; otherwise the store consumes the pending id on mount.
|
||||||
|
*/
|
||||||
|
export function openThemeStoreWithHighlight(themeId: string): void {
|
||||||
|
pendingHighlightThemeId = themeId;
|
||||||
|
|
||||||
|
const existing = document.getElementById("store");
|
||||||
|
if (existing) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("bsplus:highlight-theme", { detail: { themeId } }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenStorePage();
|
||||||
|
}
|
||||||
@@ -36,6 +36,10 @@ export interface SettingsState {
|
|||||||
engageParentsAnnouncementShown?: boolean;
|
engageParentsAnnouncementShown?: boolean;
|
||||||
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
|
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
|
||||||
bsCloudAutoSyncAnnouncementShown?: boolean;
|
bsCloudAutoSyncAnnouncementShown?: boolean;
|
||||||
|
/** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */
|
||||||
|
themeOfTheMonthLastSeenId?: string;
|
||||||
|
/** Permanently disables Theme of the Month startup prompts. */
|
||||||
|
themeOfTheMonthDisabled?: boolean;
|
||||||
timeFormat?: string;
|
timeFormat?: string;
|
||||||
animations: boolean;
|
animations: boolean;
|
||||||
defaultPage: string;
|
defaultPage: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user