mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Merge branch 'main' into improved-global-search
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
|
||||
|
||||
function randomEngagePdfFileName(): string {
|
||||
const token = Math.random().toString(36).slice(2, 10);
|
||||
return `${token}.pdf`;
|
||||
}
|
||||
|
||||
export async function requestEngageAssessmentPdf(params: {
|
||||
assessmentID: string | number;
|
||||
metaclassID: string | number;
|
||||
studentID: string | number;
|
||||
}): Promise<string> {
|
||||
const fileName = randomEngagePdfFileName();
|
||||
const cacheBuster = Math.random().toString(36).slice(2, 10);
|
||||
|
||||
const response = await fetch(
|
||||
`${location.origin}/seqta/parent/print/assessment?${cacheBuster}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
id: params.assessmentID,
|
||||
metaclass: params.metaclassID,
|
||||
student: Number(params.studentID),
|
||||
fileName,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to generate PDF: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
payload?: { file?: string };
|
||||
};
|
||||
|
||||
return data.payload?.file ?? fileName;
|
||||
}
|
||||
|
||||
export function getEngageAssessmentReportUrl(fileName: string): string {
|
||||
return `${location.origin}/seqta/parent/report/get?file=${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
import { type Plugin } from "@/plugins/core/types";
|
||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
||||
import {
|
||||
clearStuck,
|
||||
getClassByPattern,
|
||||
initStorage,
|
||||
injectWeightingsTab,
|
||||
letterToNumber,
|
||||
parseAssessments,
|
||||
processAssessments,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
interface weightingsStorage {
|
||||
weightings: Record<string, string>;
|
||||
assessments: Record<string, string>;
|
||||
weightingOverrides: Record<string, string>;
|
||||
}
|
||||
|
||||
const settings = defineSettings({
|
||||
@@ -37,6 +38,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
||||
|
||||
const instance = new AssessmentsAveragePluginClass();
|
||||
|
||||
let overrideListenerController: AbortController | null = null;
|
||||
|
||||
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
id: "assessments-average",
|
||||
name: "Assessment Averages",
|
||||
@@ -58,143 +61,149 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
);
|
||||
|
||||
await parseAssessments(api);
|
||||
|
||||
const sampleAssessmentItem = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___']",
|
||||
await renderSubjectAverage(api);
|
||||
overrideListenerController?.abort();
|
||||
overrideListenerController = new AbortController();
|
||||
document.addEventListener(
|
||||
"betterseqta:overrideChanged",
|
||||
() => renderSubjectAverage(api),
|
||||
{ signal: overrideListenerController.signal },
|
||||
);
|
||||
if (!sampleAssessmentItem) return;
|
||||
|
||||
const assessmentItemClass =
|
||||
Array.from(sampleAssessmentItem.classList).find((c) =>
|
||||
c.startsWith("AssessmentItem__AssessmentItem___"),
|
||||
) || "";
|
||||
|
||||
const metaContainerClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__metaContainer___",
|
||||
);
|
||||
const metaClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__meta___",
|
||||
);
|
||||
const simpleResultClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__simpleResult___",
|
||||
);
|
||||
const titleClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"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(
|
||||
assessmentsList.querySelectorAll(
|
||||
`[class*='AssessmentItem__AssessmentItem___']`,
|
||||
),
|
||||
).filter(
|
||||
(item) =>
|
||||
!item
|
||||
.querySelector(`[class*='AssessmentItem__title___']`)
|
||||
?.textContent?.includes("Subject Average"),
|
||||
);
|
||||
|
||||
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
||||
await processAssessments(api, assessmentItems);
|
||||
|
||||
if (!count || totalWeight === 0) return;
|
||||
|
||||
const avg = weightedTotal / totalWeight;
|
||||
const rounded = Math.ceil(avg / 5) * 5;
|
||||
const numberToLetter = Object.entries(letterToNumber).reduce(
|
||||
(acc, [k, v]) => {
|
||||
acc[v] = k;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
|
||||
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
||||
const display = api.settings.lettergrade
|
||||
? letterAvg
|
||||
: `${avg.toFixed(2)}%`;
|
||||
|
||||
const existing = assessmentsList.querySelector(
|
||||
`[class*='AssessmentItem__title___']`,
|
||||
);
|
||||
if (existing?.textContent === "Subject Average") return;
|
||||
|
||||
let warningHTML = "";
|
||||
if (hasInaccurateWeighting) {
|
||||
warningHTML = /* html */ `
|
||||
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
|
||||
⚠ Some weightings unavailable
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
assessmentsList.insertBefore(
|
||||
stringToHTML(/* html */ `
|
||||
<div class="${assessmentItemClass}">
|
||||
<div class="${metaContainerClass}">
|
||||
<div class="${metaClass}">
|
||||
<div class="${simpleResultClass}">
|
||||
<div class="${titleClass}">Subject Average</div>
|
||||
${warningHTML}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${thermoscoreClass}">
|
||||
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
||||
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).firstChild!,
|
||||
assessmentsList.firstChild,
|
||||
);
|
||||
|
||||
applySubjectColourToOverallResult();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
applySubjectColourToOverallResult();
|
||||
});
|
||||
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(
|
||||
"[class*='AssessmentItem__AssessmentItem___']",
|
||||
);
|
||||
if (!sampleAssessmentItem) return;
|
||||
const assessmentItemClass =
|
||||
Array.from(sampleAssessmentItem.classList).find((c) =>
|
||||
c.startsWith("AssessmentItem__AssessmentItem___"),
|
||||
) || "";
|
||||
const metaContainerClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__metaContainer___",
|
||||
);
|
||||
const metaClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__meta___",
|
||||
);
|
||||
const simpleResultClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__simpleResult___",
|
||||
);
|
||||
const titleClass = getClassByPattern(
|
||||
sampleAssessmentItem,
|
||||
"AssessmentItem__title___",
|
||||
);
|
||||
|
||||
const assessmentItems = Array.from(
|
||||
assessmentsList.querySelectorAll(
|
||||
`[class*='AssessmentItem__AssessmentItem___']`,
|
||||
),
|
||||
).filter(
|
||||
(item) =>
|
||||
!item
|
||||
.querySelector(`[class*='AssessmentItem__title___']`)
|
||||
?.textContent?.includes("Subject Average"),
|
||||
);
|
||||
|
||||
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
||||
await processAssessments(api, assessmentItems);
|
||||
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 rounded = Math.ceil(avg / 5) * 5;
|
||||
const numberToLetter = Object.entries(letterToNumber).reduce(
|
||||
(acc, [k, v]) => {
|
||||
acc[v] = k;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
||||
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
|
||||
let warningHTML = "";
|
||||
if (hasInaccurateWeighting) {
|
||||
warningHTML = /* html */ `
|
||||
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
|
||||
⚠ Some weightings unavailable
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
assessmentsList.insertBefore(
|
||||
stringToHTML(/* html */ `
|
||||
<div class="${assessmentItemClass}">
|
||||
<div class="${metaContainerClass}">
|
||||
<div class="${metaClass}">
|
||||
<div class="${simpleResultClass}">
|
||||
<div class="${titleClass}">Subject Average</div>
|
||||
${warningHTML}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${thermoscoreClass}">
|
||||
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
||||
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).firstChild!,
|
||||
assessmentsList.firstChild,
|
||||
);
|
||||
applySubjectColourToOverallResult();
|
||||
} finally {
|
||||
renderInFlight = false;
|
||||
}
|
||||
}
|
||||
function applySubjectColourToOverallResult() {
|
||||
const selectedAssessmentItem = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements.ts";
|
||||
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
|
||||
import {
|
||||
getEngageAssessmentReportUrl,
|
||||
requestEngageAssessmentPdf,
|
||||
} from "./engage.ts";
|
||||
import {
|
||||
ensurePdfjsWorker,
|
||||
getPdfjsPageContextUrls,
|
||||
@@ -17,6 +23,9 @@ export async function initStorage(api: any) {
|
||||
if (!api.storage.assessments) {
|
||||
api.storage.assessments = {};
|
||||
}
|
||||
if (!api.storage.weightingOverrides) {
|
||||
api.storage.weightingOverrides = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStuck(api: any) {
|
||||
@@ -79,52 +88,84 @@ function createWeightLabel(
|
||||
assessmentItem: Element,
|
||||
weighting: string | undefined,
|
||||
) {
|
||||
const statsContainer = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__stats___']`,
|
||||
) as HTMLElement;
|
||||
let statsContainer = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (
|
||||
!statsContainer ||
|
||||
statsContainer.querySelector(".betterseqta-weight-label")
|
||||
)
|
||||
return;
|
||||
if (!statsContainer) {
|
||||
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
|
||||
statsContainer = document.createElement("div");
|
||||
statsContainer.className = statsClass;
|
||||
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(
|
||||
`[class*='Label__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___']`,
|
||||
const hasNativeLabel = !!statsContainer.querySelector(
|
||||
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
|
||||
);
|
||||
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";
|
||||
|
||||
const textNodes = Array.from(weightLabel.childNodes).filter(
|
||||
(node) => node.nodeType === Node.TEXT_NODE,
|
||||
);
|
||||
|
||||
if (textNodes.length) {
|
||||
textNodes[0].textContent =
|
||||
weighting && weighting !== "processing"
|
||||
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
|
||||
: "N/A";
|
||||
textNodes[0].textContent = displayText;
|
||||
} else {
|
||||
weightLabel.appendChild(document.createTextNode(displayText));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -228,7 +269,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
export async function extractPDFText(url: string): Promise<string> {
|
||||
try {
|
||||
if (isFirefox) {
|
||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
|
||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
|
||||
getPdfjsPageContextUrls();
|
||||
const escJsSingleQuoted = (s: string) =>
|
||||
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||
@@ -428,8 +470,6 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
async function handleWeightings(mark: any, api: any) {
|
||||
const assessmentID = mark.id;
|
||||
const metaclassID = mark.metaclassID;
|
||||
const userInfo = await getUserInfo();
|
||||
const userID = userInfo.id;
|
||||
const title = mark.title;
|
||||
|
||||
if (
|
||||
@@ -450,35 +490,55 @@ async function handleWeightings(mark: any, api: any) {
|
||||
};
|
||||
|
||||
try {
|
||||
const filename =
|
||||
"BetterSEQTA-" +
|
||||
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
|
||||
let pdfUrl: string;
|
||||
|
||||
const printResponse = await fetch(
|
||||
`${location.origin}/seqta/student/print/assessment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
fileName: filename,
|
||||
id: assessmentID,
|
||||
metaclass: metaclassID,
|
||||
student: userID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (isSeqtaEngageExperience()) {
|
||||
const studentID = getEngageAssessmentStudentId();
|
||||
if (!studentID) {
|
||||
throw new Error("Could not resolve Engage student ID from URL or storage");
|
||||
}
|
||||
|
||||
if (!printResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
||||
const reportFile = await requestEngageAssessmentPdf({
|
||||
assessmentID,
|
||||
metaclassID,
|
||||
studentID,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
pdfUrl = getEngageAssessmentReportUrl(reportFile);
|
||||
} else {
|
||||
const userInfo = await getUserInfo();
|
||||
const userID = userInfo.id;
|
||||
|
||||
const filename =
|
||||
"BetterSEQTA-" +
|
||||
String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
|
||||
|
||||
const printResponse = await fetch(
|
||||
`${location.origin}/seqta/student/print/assessment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
fileName: filename,
|
||||
id: assessmentID,
|
||||
metaclass: metaclassID,
|
||||
student: userID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!printResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
|
||||
|
||||
if (pdfUrl.startsWith("blob:")) {
|
||||
throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
|
||||
}
|
||||
@@ -519,7 +579,11 @@ export async function parseAssessments(api: any) {
|
||||
"[class*='AssessmentList__items___']",
|
||||
).getState();
|
||||
|
||||
const marks = state["marks"];
|
||||
const marks = [
|
||||
...(state["marks"] ?? []),
|
||||
...(state["upcoming"] ?? []),
|
||||
...(state["pending"] ?? []),
|
||||
];
|
||||
if (!marks) return;
|
||||
|
||||
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
||||
@@ -532,15 +596,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
let count = 0;
|
||||
|
||||
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(
|
||||
`[class*='AssessmentItem__title___']`,
|
||||
);
|
||||
@@ -550,12 +605,23 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
if (!title) continue;
|
||||
|
||||
const assessmentID = api.storage.assessments?.[title];
|
||||
const weighting = assessmentID
|
||||
const autoWeighting = assessmentID
|
||||
? api.storage.weightings?.[assessmentID]
|
||||
: undefined;
|
||||
const override = assessmentID
|
||||
? api.storage.weightingOverrides?.[assessmentID]
|
||||
: undefined;
|
||||
const weighting = override ?? autoWeighting;
|
||||
|
||||
createWeightLabel(assessmentItem, weighting);
|
||||
|
||||
const gradeElement = assessmentItem.querySelector(
|
||||
`[class*='Thermoscore__text___']`,
|
||||
);
|
||||
if (!gradeElement) continue;
|
||||
const grade = parseGrade(gradeElement.textContent || "");
|
||||
if (grade <= 0) continue;
|
||||
|
||||
if (
|
||||
weighting === null ||
|
||||
weighting === undefined ||
|
||||
@@ -563,8 +629,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
weighting === "processing"
|
||||
) {
|
||||
hasInaccurateWeighting = true;
|
||||
weightedTotal += grade;
|
||||
totalWeight += 1;
|
||||
continue
|
||||
} else {
|
||||
const weight = parseFloat(weighting);
|
||||
|
||||
@@ -587,3 +652,271 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { determineStatus, formatDate, getGradeValue } from "./utils";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import { buildEngageAssessmentPagePath } from "@/seqta/utils/engageAssessmentStudent";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
export let data: any;
|
||||
|
||||
interface FilterOptions {
|
||||
subject: string;
|
||||
student: string;
|
||||
sortBy: "due" | "grade" | "subject" | "title" | "year";
|
||||
}
|
||||
|
||||
@@ -38,9 +41,13 @@
|
||||
|
||||
let currentFilters: FilterOptions = {
|
||||
subject: "all",
|
||||
student: "all",
|
||||
sortBy: "due",
|
||||
};
|
||||
|
||||
const isEngage = isSeqtaEngageExperience();
|
||||
$: showStudentFilter = isEngage && (data?.students?.length ?? 0) > 1;
|
||||
|
||||
let filteredAssessments: any[] = [];
|
||||
let statusGroups: Record<string, any[]> = {};
|
||||
let columns: { key: string; title: string; className: string; icon: string }[] = [];
|
||||
@@ -100,7 +107,17 @@
|
||||
const filtered = data.assessments.filter((a: any) => {
|
||||
if (hiddenAssessmentIds.has(String(a.id))) return false;
|
||||
if (subjectFilters[a.code] === false) return false;
|
||||
return currentFilters.subject === "all" || a.code === currentFilters.subject;
|
||||
if (currentFilters.subject !== "all" && a.code !== currentFilters.subject) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
isEngage &&
|
||||
currentFilters.student !== "all" &&
|
||||
String(a.studentId) !== currentFilters.student
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const groups: Record<string, any[]> = {};
|
||||
@@ -309,6 +326,19 @@
|
||||
if ((event.target as HTMLElement).closest(".card-menu")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSeqtaEngageExperience()) {
|
||||
const studentId = assessment.studentId ?? data?.studentId;
|
||||
if (!studentId) return;
|
||||
window.location.hash = buildEngageAssessmentPagePath(
|
||||
studentId,
|
||||
assessment.programmeID,
|
||||
assessment.metaclassID,
|
||||
assessment.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
|
||||
}
|
||||
|
||||
@@ -342,6 +372,7 @@
|
||||
updateAssessments();
|
||||
void currentFilters.sortBy;
|
||||
void currentFilters.subject;
|
||||
void currentFilters.student;
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -352,6 +383,14 @@
|
||||
<div class="grid-view-header">
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
{#if showStudentFilter}
|
||||
<select class="filter-select" bind:value={currentFilters.student}>
|
||||
<option value="all">All Students</option>
|
||||
{#each data.students as student}
|
||||
<option value={String(student.id)}>{student.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<select class="filter-select" bind:value={currentFilters.subject}>
|
||||
<option value="all">All Subjects</option>
|
||||
{#each data.subjects as subject}
|
||||
@@ -445,6 +484,9 @@
|
||||
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
|
||||
>
|
||||
<div class="card-labels">
|
||||
{#if isEngage && assessment.studentName}
|
||||
<span class="card-label label-student">{assessment.studentName}</span>
|
||||
{/if}
|
||||
<span class="card-label label-subject">{assessment.code}</span>
|
||||
{#if assessment.submitted}
|
||||
<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>
|
||||
|
||||
@@ -9,12 +9,17 @@ interface PrefItem {
|
||||
value: string;
|
||||
}
|
||||
|
||||
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { getMockAssessmentsData } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import {
|
||||
getEngageAssessmentsData,
|
||||
} from "./engageApi";
|
||||
|
||||
let cache: { time: number; data: any } | null = null;
|
||||
let cache: { time: number; engageAll?: boolean; studentId: number; data: any } | null =
|
||||
null;
|
||||
const CACHE_MS = 10 * 60 * 1000;
|
||||
const student = 69;
|
||||
|
||||
async function fetchJSON(url: string, body: any) {
|
||||
const res = await fetch(`${location.origin}${url}`, {
|
||||
@@ -28,17 +33,9 @@ async function fetchJSON(url: string, body: any) {
|
||||
|
||||
async function loadSubjects() {
|
||||
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
||||
const activeGroup = res.payload.find((s: any) => s.active === 1);
|
||||
const activeYear = activeGroup?.year;
|
||||
const allSubjects = res.payload
|
||||
.filter((s: any) => s.year === activeYear)
|
||||
return res.payload
|
||||
.filter((s: any) => s.active === 1)
|
||||
.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) {
|
||||
@@ -66,7 +63,6 @@ async function loadUpcoming(student: number) {
|
||||
|
||||
function normalizeAssessmentDates(t: any, subject: Subject): any {
|
||||
const normalized = { ...t };
|
||||
// Past API may use different date fields - ensure we have 'due' for year filter & display
|
||||
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
||||
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
||||
}
|
||||
@@ -136,18 +132,13 @@ async function loadSubmissions(student: number, assessments: any[]) {
|
||||
return submissionMap;
|
||||
}
|
||||
|
||||
export async function getAssessmentsData() {
|
||||
if (settingsState.mockNotices) {
|
||||
return getMockAssessmentsData();
|
||||
}
|
||||
|
||||
if (cache && Date.now() - cache.time < CACHE_MS) return cache.data;
|
||||
async function getLearnAssessmentsData(studentId: number) {
|
||||
const [subjects, colors, upcoming] = await Promise.all([
|
||||
loadSubjects(),
|
||||
loadPrefs(student),
|
||||
loadUpcoming(student),
|
||||
loadPrefs(studentId),
|
||||
loadUpcoming(studentId),
|
||||
]);
|
||||
const pastMap = await loadPast(student, subjects);
|
||||
const pastMap = await loadPast(studentId, subjects);
|
||||
const map: Record<number, any> = {};
|
||||
upcoming.forEach((a: any) => {
|
||||
map[a.id] = { ...a };
|
||||
@@ -158,13 +149,42 @@ export async function getAssessmentsData() {
|
||||
});
|
||||
|
||||
const allAssessments = Object.values(map);
|
||||
const submissions = await loadSubmissions(student, allAssessments);
|
||||
const submissions = await loadSubmissions(studentId, allAssessments);
|
||||
|
||||
allAssessments.forEach((assessment: any) => {
|
||||
assessment.submitted = submissions[assessment.id] || false;
|
||||
});
|
||||
|
||||
const data = { assessments: allAssessments, subjects, colors };
|
||||
cache = { time: Date.now(), data };
|
||||
return { assessments: allAssessments, subjects, colors, studentId };
|
||||
}
|
||||
|
||||
export async function getAssessmentsData() {
|
||||
if (settingsState.mockNotices) {
|
||||
return getMockAssessmentsData();
|
||||
}
|
||||
|
||||
if (isSeqtaEngageExperience()) {
|
||||
if (cache && Date.now() - cache.time < CACHE_MS && cache.engageAll) {
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
const data = await getEngageAssessmentsData();
|
||||
cache = { time: Date.now(), studentId: 0, engageAll: true, data };
|
||||
return data;
|
||||
}
|
||||
|
||||
const studentId = (await getUserInfo()).id;
|
||||
|
||||
if (
|
||||
cache &&
|
||||
Date.now() - cache.time < CACHE_MS &&
|
||||
cache.studentId === studentId
|
||||
) {
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
const data = await getLearnAssessmentsData(studentId);
|
||||
|
||||
cache = { time: Date.now(), studentId, data };
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { getEngageAssessmentStudentId } from "@/seqta/utils/engageAssessmentStudent";
|
||||
|
||||
interface Subject {
|
||||
code: string;
|
||||
programme: number;
|
||||
metaclass: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface PrefItem {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EngageStudent {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface EngageChildPayload {
|
||||
id?: number;
|
||||
name?: string;
|
||||
terms?: {
|
||||
active?: number;
|
||||
subjects?: {
|
||||
code?: string;
|
||||
programme?: number;
|
||||
metaclass?: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
async function fetchJSON(url: string, body: unknown) {
|
||||
const res = await fetch(`${location.origin}${url}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function loadEngageChildrenPayload(): Promise<EngageChildPayload[]> {
|
||||
const res = await fetchJSON("/seqta/parent/load/subjects", {});
|
||||
return Array.isArray(res.payload) ? res.payload : [];
|
||||
}
|
||||
|
||||
export async function resolveEngageStudentId(): Promise<number> {
|
||||
const fromUrlOrStorage = getEngageAssessmentStudentId();
|
||||
if (fromUrlOrStorage) return Number(fromUrlOrStorage);
|
||||
|
||||
const children = await loadEngageChildrenPayload();
|
||||
const firstChild = children[0];
|
||||
if (firstChild?.id != null) return Number(firstChild.id);
|
||||
|
||||
throw new Error("Could not resolve Engage student ID");
|
||||
}
|
||||
|
||||
function subjectsFromChild(child: EngageChildPayload): Subject[] {
|
||||
return (child.terms ?? [])
|
||||
.filter((term) => term.active === 1)
|
||||
.flatMap((term) =>
|
||||
(term.subjects ?? []).map((subject) => ({
|
||||
code: subject.code ?? "",
|
||||
programme: subject.programme ?? 0,
|
||||
metaclass: subject.metaclass ?? 0,
|
||||
title: subject.title ?? subject.description ?? subject.code ?? "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadEngagePrefs(): Promise<Record<string, string>> {
|
||||
const res = await fetchJSON("/seqta/parent/load/prefs?", {
|
||||
request: "userPrefs",
|
||||
asArray: true,
|
||||
});
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
(res.payload ?? []).forEach((pref: PrefItem) => {
|
||||
if (pref.name.startsWith("timetable.subject.colour.")) {
|
||||
const code = pref.name.replace("timetable.subject.colour.", "");
|
||||
colors[code] = pref.value;
|
||||
}
|
||||
});
|
||||
return colors;
|
||||
}
|
||||
|
||||
async function loadEngageUpcoming(studentId: number) {
|
||||
const res = await fetchJSON("/seqta/parent/assessment/list/upcoming?", {
|
||||
student: studentId,
|
||||
});
|
||||
return res.payload ?? [];
|
||||
}
|
||||
|
||||
function normalizeAssessmentDates(t: any, subject: Subject): any {
|
||||
const normalized = { ...t };
|
||||
if (!normalized.due && (t.date || t.dueDate || t.created || t.submittedDate)) {
|
||||
normalized.due = t.date || t.dueDate || t.created || t.submittedDate;
|
||||
}
|
||||
if (!normalized.programmeID) normalized.programmeID = subject.programme;
|
||||
if (!normalized.metaclassID) normalized.metaclassID = subject.metaclass;
|
||||
if (!normalized.code && t.subject) normalized.code = t.subject;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function loadEngagePast(studentId: number, subjects: Subject[]) {
|
||||
const map: Record<number, any> = {};
|
||||
|
||||
await Promise.all(
|
||||
subjects.map(async (subject) => {
|
||||
const res = await fetchJSON("/seqta/parent/assessment/list/past?", {
|
||||
programme: subject.programme,
|
||||
metaclass: subject.metaclass,
|
||||
student: studentId,
|
||||
});
|
||||
|
||||
const processAssessment = (task: any) => {
|
||||
if (task?.id) {
|
||||
const merged = {
|
||||
...task,
|
||||
programmeID: task.programmeID || task.programme || subject.programme,
|
||||
metaclassID: task.metaclassID || task.metaclass || subject.metaclass,
|
||||
code: task.code || task.subject || subject.code,
|
||||
};
|
||||
map[task.id] = normalizeAssessmentDates(merged, subject);
|
||||
}
|
||||
};
|
||||
|
||||
if (Array.isArray(res.payload?.pending)) {
|
||||
res.payload.pending.forEach(processAssessment);
|
||||
}
|
||||
if (Array.isArray(res.payload?.tasks)) {
|
||||
res.payload.tasks.forEach(processAssessment);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
async function loadEngageSubmissions(studentId: number, assessments: any[]) {
|
||||
const submissionMap: Record<number, boolean> = {};
|
||||
|
||||
await Promise.all(
|
||||
assessments.map(async (assessment) => {
|
||||
try {
|
||||
const res = await fetchJSON("/seqta/parent/assessment/submissions/get", {
|
||||
assessment: assessment.id,
|
||||
metaclass: assessment.metaclassID,
|
||||
student: studentId,
|
||||
});
|
||||
submissionMap[assessment.id] =
|
||||
Array.isArray(res.payload) && res.payload.length > 0;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[BetterSEQTA+] Failed to fetch Engage submission for assessment ${assessment.id}:`,
|
||||
error,
|
||||
);
|
||||
submissionMap[assessment.id] = false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return submissionMap;
|
||||
}
|
||||
|
||||
async function loadEngageAssessmentsForStudent(
|
||||
child: EngageChildPayload,
|
||||
): Promise<any[]> {
|
||||
const studentId = Number(child.id);
|
||||
const studentName = child.name ?? "Student";
|
||||
const subjects = subjectsFromChild(child);
|
||||
const [upcoming, pastMap] = await Promise.all([
|
||||
loadEngageUpcoming(studentId),
|
||||
loadEngagePast(studentId, subjects),
|
||||
]);
|
||||
|
||||
const map: Record<number, any> = {};
|
||||
upcoming.forEach((assessment: any) => {
|
||||
map[assessment.id] = { ...assessment };
|
||||
});
|
||||
Object.values(pastMap).forEach((task: any) => {
|
||||
if (map[task.id]) Object.assign(map[task.id], task);
|
||||
else map[task.id] = task;
|
||||
});
|
||||
|
||||
const assessments = Object.values(map).map((assessment) => ({
|
||||
...assessment,
|
||||
studentId,
|
||||
studentName,
|
||||
}));
|
||||
|
||||
const submissions = await loadEngageSubmissions(studentId, assessments);
|
||||
assessments.forEach((assessment) => {
|
||||
assessment.submitted = submissions[assessment.id] || false;
|
||||
});
|
||||
|
||||
return assessments;
|
||||
}
|
||||
|
||||
export async function getEngageAssessmentsData() {
|
||||
const childrenPayload = await loadEngageChildrenPayload();
|
||||
const students: EngageStudent[] = childrenPayload
|
||||
.filter((child) => child.id != null)
|
||||
.map((child) => ({
|
||||
id: Number(child.id),
|
||||
name: child.name ?? "Student",
|
||||
}));
|
||||
|
||||
if (!students.length) {
|
||||
throw new Error("No Engage students found");
|
||||
}
|
||||
|
||||
const [colors, assessmentsByChild] = await Promise.all([
|
||||
loadEngagePrefs(),
|
||||
Promise.all(childrenPayload.map((child) => loadEngageAssessmentsForStudent(child))),
|
||||
]);
|
||||
|
||||
const subjectsMap = new Map<string, Subject>();
|
||||
childrenPayload.forEach((child) => {
|
||||
subjectsFromChild(child).forEach((subject) => {
|
||||
if (!subjectsMap.has(subject.code)) {
|
||||
subjectsMap.set(subject.code, subject);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const defaultStudentId = await resolveEngageStudentId();
|
||||
|
||||
return {
|
||||
assessments: assessmentsByChild.flat(),
|
||||
subjects: Array.from(subjectsMap.values()),
|
||||
colors,
|
||||
students,
|
||||
studentId: defaultStudentId,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,46 @@ import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
|
||||
import styles from "./styles.css?inline";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import {
|
||||
isEngageAssessmentOverviewRoute,
|
||||
} from "@/seqta/utils/engageAssessmentStudent";
|
||||
import { resolveEngageStudentId } from "./engageApi";
|
||||
|
||||
const OVERVIEW_MENU_CLASS = "betterseqta-assessments-overview-item";
|
||||
|
||||
function ensureOverviewMenuPosition(
|
||||
menu: HTMLElement,
|
||||
gridItem: HTMLElement,
|
||||
) {
|
||||
if (menu.firstElementChild !== gridItem) {
|
||||
menu.insertBefore(gridItem, menu.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function isOverviewRoute() {
|
||||
if (isSeqtaEngageExperience()) {
|
||||
return isEngageAssessmentOverviewRoute();
|
||||
}
|
||||
return window.location.hash.includes("/assessments/overview");
|
||||
}
|
||||
|
||||
async function waitForAssessmentsSubmenu(): Promise<HTMLElement> {
|
||||
if (!isSeqtaEngageExperience()) {
|
||||
return (await waitForElm(
|
||||
'[data-key="assessments"] > .sub > ul',
|
||||
true,
|
||||
100,
|
||||
60,
|
||||
)) as HTMLElement;
|
||||
}
|
||||
|
||||
return (await waitForElm(
|
||||
'[data-key="assessments"] .sub ul, [data-key="assessments"] ul',
|
||||
true,
|
||||
100,
|
||||
350,
|
||||
)) as HTMLElement;
|
||||
}
|
||||
|
||||
const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
id: "assessments-overview",
|
||||
@@ -17,35 +57,46 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
styles,
|
||||
|
||||
run: async () => {
|
||||
if (isSeqtaEngageExperience()) return;
|
||||
|
||||
const menu = (await waitForElm(
|
||||
'[data-key="assessments"] > .sub > ul',
|
||||
true,
|
||||
100,
|
||||
60,
|
||||
)) as HTMLElement;
|
||||
const menu = await waitForAssessmentsSubmenu();
|
||||
const gridItem = document.createElement("li");
|
||||
gridItem.className = "item";
|
||||
gridItem.classList.add(OVERVIEW_MENU_CLASS);
|
||||
const label = document.createElement("label");
|
||||
label.textContent = "Overview";
|
||||
gridItem.appendChild(label);
|
||||
menu.insertBefore(gridItem, menu.children[1] || null);
|
||||
menu.insertBefore(gridItem, menu.firstChild);
|
||||
|
||||
if (window.location.hash.includes("/assessments/overview")) {
|
||||
loadGridView();
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
ensureOverviewMenuPosition(menu, gridItem);
|
||||
});
|
||||
menuObserver.observe(menu, { childList: true });
|
||||
|
||||
if (isOverviewRoute()) {
|
||||
void loadGridView();
|
||||
}
|
||||
|
||||
const clickHandler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
loadGridView();
|
||||
void loadGridView();
|
||||
};
|
||||
gridItem.addEventListener("click", clickHandler);
|
||||
|
||||
async function loadGridView() {
|
||||
await delay(1);
|
||||
window.history.pushState({}, "", "/#?page=/assessments/overview");
|
||||
document.title = "Overview ― SEQTA Learn";
|
||||
|
||||
if (isSeqtaEngageExperience()) {
|
||||
const studentId = await resolveEngageStudentId();
|
||||
window.history.pushState(
|
||||
{},
|
||||
"",
|
||||
`/#?page=/assessments/${studentId}/overview`,
|
||||
);
|
||||
document.title = "Overview ― SEQTA Engage";
|
||||
} else {
|
||||
window.history.pushState({}, "", "/#?page=/assessments/overview");
|
||||
document.title = "Overview ― SEQTA Learn";
|
||||
}
|
||||
|
||||
const main = document.getElementById("main");
|
||||
if (!main) return;
|
||||
|
||||
@@ -79,6 +130,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
}
|
||||
|
||||
return () => {
|
||||
menuObserver.disconnect();
|
||||
gridItem.removeEventListener("click", clickHandler);
|
||||
gridItem.remove();
|
||||
};
|
||||
|
||||
@@ -245,6 +245,15 @@
|
||||
background: var(--subject-color, #d41e3a);
|
||||
}
|
||||
|
||||
.label-student {
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.dark .label-student {
|
||||
background: rgba(99, 102, 241, 0.75);
|
||||
}
|
||||
|
||||
.card-menu {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
defineSettings,
|
||||
hotkeySetting,
|
||||
} from "../../core/settingsHelpers";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
import styles from "./src/core/styles.css?inline";
|
||||
import { resetSearchIndexes } from "./src/indexing/resetIndexes";
|
||||
|
||||
@@ -70,7 +71,7 @@ const settings = defineSettings({
|
||||
});
|
||||
|
||||
// Create the lazy plugin definition - this loads immediately but doesn't import heavy dependencies
|
||||
export default defineLazyPlugin({
|
||||
const globalSearchPlugin = defineLazyPlugin({
|
||||
id: "global-search",
|
||||
name: "Global Search",
|
||||
description: "Quick search for everything in SEQTA",
|
||||
@@ -83,3 +84,15 @@ export default defineLazyPlugin({
|
||||
// Lazy loader - only imports the heavy plugin when actually needed
|
||||
loader: () => import("./src/core/index")
|
||||
});
|
||||
|
||||
const runGlobalSearch = globalSearchPlugin.run!;
|
||||
|
||||
globalSearchPlugin.run = async (api) => {
|
||||
if (isSeqtaEngageExperience()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
return runGlobalSearch(api);
|
||||
};
|
||||
|
||||
export default globalSearchPlugin;
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
interface MessageFoldersStorage {
|
||||
@@ -34,12 +35,33 @@ const FOLDER_COLORS = [
|
||||
"#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 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 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 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 {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
@@ -49,7 +71,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
id: "messageFolders",
|
||||
name: "Message Folders",
|
||||
description: "Organize direct messages into custom folders",
|
||||
version: "1.0.0",
|
||||
version: "2.0.0",
|
||||
settings: messageFoldersSettings,
|
||||
disableToggle: true,
|
||||
defaultEnabled: true,
|
||||
@@ -70,10 +92,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
let actionsObserver: MutationObserver | null = null;
|
||||
let openDropdown: HTMLElement | null = null;
|
||||
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
|
||||
let foldedSection: HTMLElement | null = null;
|
||||
const unregisters: Array<{ unregister: () => void }> = [];
|
||||
|
||||
// ── Storage accessors ──
|
||||
|
||||
const getFolders = (): Folder[] => api.storage.folders ?? [];
|
||||
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
|
||||
|
||||
@@ -94,6 +115,18 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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 assignments = getAssignments();
|
||||
if (!assignments[folderId]) assignments[folderId] = [];
|
||||
@@ -129,16 +162,28 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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 = (
|
||||
title: string,
|
||||
message: string,
|
||||
onConfirm: () => void,
|
||||
) => {
|
||||
const getMessageIdFromEvent = (target: HTMLElement): string | null => {
|
||||
const li = target.closest("li[data-message]");
|
||||
return li?.getAttribute("data-message") ?? null;
|
||||
};
|
||||
|
||||
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");
|
||||
overlay.className = "bsplus-modal-overlay";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "bsplus-modal";
|
||||
modal.innerHTML = `
|
||||
@@ -150,16 +195,13 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(modal);
|
||||
|
||||
const remove = () => {
|
||||
overlay.remove();
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") remove();
|
||||
};
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) remove();
|
||||
});
|
||||
@@ -168,36 +210,42 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
onConfirm();
|
||||
remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.addEventListener("keydown", onKey);
|
||||
};
|
||||
|
||||
// ── Sidebar folder UI ──
|
||||
|
||||
const renderSidebarFolders = () => {
|
||||
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||
if (!sidebar) return;
|
||||
|
||||
const ol = sidebar.querySelector("ol");
|
||||
if (!ol) return;
|
||||
|
||||
let section = ol.querySelector(".bsplus-folders-section");
|
||||
let section = ol.querySelector(".bsplus-folders-section") as HTMLElement;
|
||||
if (!section) {
|
||||
section = document.createElement("div");
|
||||
section.className = "bsplus-folders-section";
|
||||
ol.appendChild(section);
|
||||
}
|
||||
|
||||
foldedSection = section;
|
||||
const folders = getFolders();
|
||||
const existingInput = section.querySelector(".bsplus-folder-input");
|
||||
const existingColors = section.querySelector(".bsplus-folder-colors");
|
||||
|
||||
section.innerHTML = "";
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
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");
|
||||
label.textContent = "Folders";
|
||||
@@ -214,9 +262,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
header.appendChild(addBtn);
|
||||
section.appendChild(header);
|
||||
|
||||
// "All Messages" item
|
||||
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 = `
|
||||
<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>
|
||||
@@ -226,20 +273,34 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
renderSidebarFolders();
|
||||
setTimeout(() => {
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
}, 100);
|
||||
});
|
||||
section.appendChild(allItem);
|
||||
|
||||
// Folder items
|
||||
for (const folder of folders) {
|
||||
const item = document.createElement("div");
|
||||
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
|
||||
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");
|
||||
dot.className = "bsplus-folder-dot";
|
||||
dot.style.background = folder.color;
|
||||
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");
|
||||
name.className = "bsplus-folder-name";
|
||||
name.textContent = folder.name;
|
||||
@@ -264,21 +325,17 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
deleteBtn.innerHTML = TRASH_SVG;
|
||||
deleteBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
showConfirmModal(
|
||||
"Delete folder",
|
||||
`Remove "${folder.name}"? Messages won't be deleted.`,
|
||||
() => {
|
||||
const folders = getFolders().filter((f) => f.id !== folder.id);
|
||||
saveFolders(folders);
|
||||
const assignments = getAssignments();
|
||||
delete assignments[folder.id];
|
||||
saveAssignments(assignments);
|
||||
if (activeFolderId === folder.id) activeFolderId = null;
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
renderSidebarFolders();
|
||||
},
|
||||
);
|
||||
showConfirmModal("Delete folder", `Remove "${folder.name}"? Messages won't be deleted.`, () => {
|
||||
const folders = getFolders().filter((f) => f.id !== folder.id);
|
||||
saveFolders(folders);
|
||||
const assignments = getAssignments();
|
||||
delete assignments[folder.id];
|
||||
saveAssignments(assignments);
|
||||
if (activeFolderId === folder.id) activeFolderId = null;
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
renderSidebarFolders();
|
||||
});
|
||||
});
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
@@ -295,15 +352,89 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
applyFolderFilter();
|
||||
applyBadges();
|
||||
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);
|
||||
}
|
||||
|
||||
// Restore input if it was open
|
||||
if (existingInput || existingColors) {
|
||||
// Don't restore – let user re-trigger
|
||||
}
|
||||
section.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
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) => {
|
||||
@@ -312,16 +443,34 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
container.querySelector(".bsplus-folder-colors")?.remove();
|
||||
|
||||
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");
|
||||
row.className = "bsplus-folder-input";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.placeholder = editFolder ? "Rename folder…" : "Folder name…";
|
||||
input.placeholder = editFolder ? "Rename folder\u2026" : "Folder name\u2026";
|
||||
input.value = editFolder?.name ?? "";
|
||||
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");
|
||||
confirmBtn.className = "bsplus-folder-input-confirm";
|
||||
confirmBtn.innerHTML = CHECK_SVG_WHITE;
|
||||
@@ -330,11 +479,11 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
cancelBtn.className = "bsplus-folder-input-cancel";
|
||||
cancelBtn.innerHTML = CLOSE_SVG;
|
||||
|
||||
row.appendChild(iconBtn);
|
||||
row.appendChild(input);
|
||||
row.appendChild(confirmBtn);
|
||||
row.appendChild(cancelBtn);
|
||||
|
||||
// Color picker
|
||||
const colorRow = document.createElement("div");
|
||||
colorRow.className = "bsplus-folder-colors";
|
||||
for (const color of FOLDER_COLORS) {
|
||||
@@ -354,14 +503,13 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
const confirm = () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
if (editFolder) {
|
||||
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);
|
||||
} else {
|
||||
const folder: Folder = { id: generateId(), name, color: selectedColor };
|
||||
const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon };
|
||||
saveFolders([...getFolders(), folder]);
|
||||
}
|
||||
applyBadges();
|
||||
@@ -386,23 +534,38 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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) => {
|
||||
showNewFolderInput(container, folder);
|
||||
};
|
||||
|
||||
// ── Intercept native sidebar clicks to clear folder filter ──
|
||||
|
||||
const attachNativeSidebarListeners = () => {
|
||||
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||
if (!sidebar) return;
|
||||
|
||||
const ol = sidebar.querySelector("ol");
|
||||
if (!ol) return;
|
||||
|
||||
ol.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".bsplus-folders-section")) return;
|
||||
|
||||
const li = target.closest("li");
|
||||
if (li && ol.contains(li)) {
|
||||
if (activeFolderId !== null) {
|
||||
@@ -415,47 +578,22 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
});
|
||||
};
|
||||
|
||||
// ── "Add to folder" button in message action bar ──
|
||||
|
||||
const injectFolderButton = (actionsBar: Element) => {
|
||||
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "bsplus-folder-btn";
|
||||
wrapper.style.position = "relative";
|
||||
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 closeDropdown = () => {
|
||||
if (openDropdown) {
|
||||
openDropdown.remove();
|
||||
openDropdown = null;
|
||||
}
|
||||
if (dropdownCloseHandler) {
|
||||
document.removeEventListener("click", dropdownCloseHandler, true);
|
||||
dropdownCloseHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
|
||||
closeDropdown();
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "bsplus-folder-dropdown";
|
||||
dropdown.dataset.msgId = messageId;
|
||||
|
||||
const folders = getFolders();
|
||||
const currentFolderIds = getMessageFolderIds(messageId);
|
||||
@@ -470,6 +608,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
const isChecked = currentFolderIds.includes(folder.id);
|
||||
const item = document.createElement("button");
|
||||
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
|
||||
item.dataset.folderId = folder.id;
|
||||
|
||||
const check = document.createElement("div");
|
||||
check.className = "bsplus-folder-dropdown-check";
|
||||
@@ -481,22 +620,26 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
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(check);
|
||||
item.appendChild(dot);
|
||||
item.appendChild(iconSpan);
|
||||
item.appendChild(name);
|
||||
|
||||
item.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleMessageInFolder(messageId, folder.id);
|
||||
|
||||
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
|
||||
item.classList.toggle("bsplus-checked", nowChecked);
|
||||
check.style.borderColor = nowChecked ? folder.color : "";
|
||||
check.style.background = nowChecked ? folder.color : "";
|
||||
|
||||
applyBadges();
|
||||
applyFolderFilter();
|
||||
renderSidebarFolders();
|
||||
@@ -519,22 +662,105 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (openDropdown) {
|
||||
openDropdown.remove();
|
||||
openDropdown = null;
|
||||
}
|
||||
if (dropdownCloseHandler) {
|
||||
document.removeEventListener("click", dropdownCloseHandler, true);
|
||||
dropdownCloseHandler = null;
|
||||
const injectFolderButton = (actionsBar: Element) => {
|
||||
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "bsplus-folder-btn";
|
||||
wrapper.style.position = "relative";
|
||||
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();
|
||||
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 messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
||||
|
||||
if (!shouldShowBadgesInList()) {
|
||||
for (const li of messageItems) {
|
||||
const subject = li.querySelector("[class*='MessageList__subject___']");
|
||||
@@ -546,26 +772,20 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const folders = getFolders();
|
||||
const assignments = getAssignments();
|
||||
|
||||
for (const li of messageItems) {
|
||||
const msgId = li.getAttribute("data-message");
|
||||
if (!msgId) continue;
|
||||
|
||||
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
|
||||
|
||||
const folderIds = [];
|
||||
const folderIds: string[] = [];
|
||||
for (const [fId, mIds] of Object.entries(assignments)) {
|
||||
if (mIds.includes(msgId)) folderIds.push(fId);
|
||||
}
|
||||
|
||||
if (folderIds.length === 0) {
|
||||
badgeContainer?.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!badgeContainer) {
|
||||
badgeContainer = document.createElement("div");
|
||||
badgeContainer.className = "bsplus-msg-badges";
|
||||
@@ -583,7 +803,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
li.appendChild(badgeContainer);
|
||||
}
|
||||
}
|
||||
|
||||
badgeContainer.innerHTML = "";
|
||||
for (const fId of folderIds) {
|
||||
const folder = folders.find((f) => f.id === fId);
|
||||
@@ -591,7 +810,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "bsplus-msg-badge";
|
||||
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.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -605,12 +824,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
}
|
||||
};
|
||||
|
||||
// ── Folder filtering ──
|
||||
|
||||
const applyFolderFilter = () => {
|
||||
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
||||
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
|
||||
|
||||
if (activeFolderId === null) {
|
||||
if (api.settings.hideFolderedMessagesInAll) {
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
|
||||
|
||||
for (const li of messageItems) {
|
||||
const msgId = li.getAttribute("data-message");
|
||||
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");
|
||||
};
|
||||
|
||||
// ── Observers ──
|
||||
|
||||
const setupMessageListObserver = () => {
|
||||
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
|
||||
if (!messageList || messageListObserver) return;
|
||||
|
||||
messageListObserver = new MutationObserver(() => {
|
||||
applyBadges();
|
||||
applyFolderFilter();
|
||||
attachDragListeners();
|
||||
attachContextMenuListeners();
|
||||
});
|
||||
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 = () => {
|
||||
if (actionsObserver) return;
|
||||
|
||||
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
|
||||
if (!target) return;
|
||||
|
||||
actionsObserver = new MutationObserver(() => {
|
||||
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
||||
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
|
||||
@@ -671,28 +895,19 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
actionsObserver.observe(target, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
// ── Main page handler ──
|
||||
|
||||
const handleMessagesPage = async () => {
|
||||
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
|
||||
|
||||
renderSidebarFolders();
|
||||
attachNativeSidebarListeners();
|
||||
|
||||
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
|
||||
applyBadges();
|
||||
applyFolderFilter();
|
||||
setupMessageListObserver();
|
||||
|
||||
// The actions bar only exists when a message is selected/open,
|
||||
// so we observe the whole viewer for it to appear dynamically
|
||||
setupActionsObserver();
|
||||
|
||||
// If a message is already selected, inject immediately
|
||||
attachDragListeners();
|
||||
attachContextMenuListeners();
|
||||
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
||||
if (actionsBar) injectFolderButton(actionsBar);
|
||||
|
||||
// Re-observe the sidebar for SEQTA re-renders
|
||||
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||
if (sidebar && !sidebarObserver) {
|
||||
sidebarObserver = new MutationObserver(() => {
|
||||
@@ -706,11 +921,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
}
|
||||
};
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
|
||||
unregisters.push(mountUnsub);
|
||||
|
||||
unregisters.push(
|
||||
api.settings.onChange("showTagsInAllMessages", () => {
|
||||
applyBadges();
|
||||
@@ -732,6 +944,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
document.querySelectorAll(".bsplus-folders-section").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-context-menu").forEach((el) => el.remove());
|
||||
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
|
||||
if (subject.querySelector(".bsplus-subject-text")) {
|
||||
restoreSubjectPlain(subject);
|
||||
@@ -741,6 +954,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
el.classList.remove("bsplus-folder-hidden"),
|
||||
);
|
||||
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));
|
||||
margin-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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 6px 12px 2px;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -20,6 +29,33 @@
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-primary, #666);
|
||||
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 {
|
||||
@@ -51,12 +87,21 @@
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
transition: background 0.15s ease, opacity 0.2s;
|
||||
position: relative;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
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 {
|
||||
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
@@ -76,6 +121,18 @@
|
||||
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 {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -83,6 +140,23 @@
|
||||
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 {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #333);
|
||||
@@ -97,6 +171,8 @@
|
||||
color: var(--text-primary, #999);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
min-width: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bsplus-folder-actions {
|
||||
@@ -158,6 +234,35 @@
|
||||
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-cancel {
|
||||
display: flex !important;
|
||||
@@ -192,6 +297,43 @@
|
||||
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 ── */
|
||||
.bsplus-folder-colors {
|
||||
display: grid;
|
||||
@@ -322,14 +464,113 @@
|
||||
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___'] {
|
||||
flex: 1 1 0% !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ── Make subject line a flex row so badges sit inline ── */
|
||||
[class*='MessageList__subject___'] {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
@@ -338,7 +579,6 @@
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ── Subject text truncates to make room for badges ── */
|
||||
.bsplus-subject-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -347,7 +587,6 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* ── Shrink the secondary column to its content ── */
|
||||
[class*='MessageList__secondary___'] {
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
@@ -355,7 +594,6 @@
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
/* ── Constrain the flags/attachment icon column ── */
|
||||
[class*='MessageList__flags___'] {
|
||||
width: 24px !important;
|
||||
min-width: 0 !important;
|
||||
@@ -391,7 +629,7 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ── Folder filtering (hide messages not in active folder) ── */
|
||||
/* ── Folder filtering ── */
|
||||
.bsplus-folder-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -489,3 +727,5 @@
|
||||
transform: translateY(-1px);
|
||||
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 { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { getApiBase } from "@/seqta/utils/DevApiBase";
|
||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||
import {
|
||||
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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,8 +145,10 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
let quickbarObserver: MutationObserver | null = null;
|
||||
let quickbarSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastClickedCi: number | null = null;
|
||||
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
|
||||
let lastSyncedQuickbarCi: number | null = null;
|
||||
|
||||
const getOverrides = (): TimetableOverrides =>
|
||||
api.storage.timetableOverrides ?? {};
|
||||
@@ -186,9 +188,11 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
|
||||
}
|
||||
|
||||
const captureClick = (e: MouseEvent) => {
|
||||
const captureClick = () => {
|
||||
lastClickedCi = ci;
|
||||
lastClickedEntry = { roomEl, teacherEl, item };
|
||||
lastSyncedQuickbarCi = null;
|
||||
scheduleQuickbarSync();
|
||||
};
|
||||
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) => {
|
||||
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.teacherEl) entryData.teacherEl.textContent = staff;
|
||||
updateVisibleQuickbar(room, staff);
|
||||
processAllEntries();
|
||||
},
|
||||
(ci) => {
|
||||
@@ -262,6 +337,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
api.storage.timetableOverridesBySubject = bySubject;
|
||||
if (entryData.roomEl) entryData.roomEl.textContent = item.room;
|
||||
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
|
||||
updateVisibleQuickbar(item.room, item.staff);
|
||||
processAllEntries();
|
||||
},
|
||||
);
|
||||
@@ -271,34 +347,30 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
};
|
||||
|
||||
const syncQuickbarFromDOM = () => {
|
||||
const quickbar = document.querySelector(
|
||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
|
||||
);
|
||||
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 quickbar = getVisibleClassQuickbar();
|
||||
if (!quickbar || lastClickedCi === null || !lastClickedEntry) return;
|
||||
syncClassQuickbar(quickbar);
|
||||
};
|
||||
|
||||
const setupQuickbarObserver = () => {
|
||||
const timetablePage = document.querySelector(".timetablepage");
|
||||
if (!timetablePage || quickbarObserver) return;
|
||||
|
||||
quickbarObserver = new MutationObserver(() => {
|
||||
const quickbar = document.querySelector(
|
||||
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
|
||||
quickbarObserver = new MutationObserver((mutations) => {
|
||||
const quickbarBecameVisible = mutations.some(
|
||||
(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") {
|
||||
addEditButtonToQuickbar(quickbar as HTMLElement);
|
||||
}
|
||||
if (!quickbarBecameVisible || lastClickedCi === null) return;
|
||||
|
||||
const quickbar = getVisibleClassQuickbar();
|
||||
if (quickbar) syncClassQuickbar(quickbar);
|
||||
});
|
||||
|
||||
quickbarObserver.observe(timetablePage, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
@@ -336,6 +408,7 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
|
||||
unregister();
|
||||
observer?.disconnect();
|
||||
quickbarObserver?.disconnect();
|
||||
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
|
||||
styleEl.remove();
|
||||
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
|
||||
el.removeAttribute("data-timetable-edit-processed");
|
||||
|
||||
+3
-76
@@ -3,10 +3,6 @@ import browser from "webextension-polyfill";
|
||||
import { animate, stagger } from "motion";
|
||||
|
||||
// Internal utilities and functions
|
||||
import {
|
||||
ChangeMenuItemPositions,
|
||||
MenuOptionsOpen,
|
||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
@@ -34,7 +30,7 @@ import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||
|
||||
// JSON content
|
||||
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
||||
import { observeMenuItemPosition } from "@/seqta/utils/sidebarMenuIcons";
|
||||
|
||||
// Icons and fonts
|
||||
import IconFamily from "@/resources/fonts/IconFamily.woff";
|
||||
@@ -105,7 +101,7 @@ export async function finishLoad() {
|
||||
console.error("Error during loading cleanup:", err);
|
||||
}
|
||||
|
||||
runStartupPopupQueue();
|
||||
void runStartupPopupQueue();
|
||||
}
|
||||
|
||||
export function GetCSSElement(file: string) {
|
||||
@@ -612,75 +608,6 @@ export function tryLoad() {
|
||||
);
|
||||
}
|
||||
|
||||
function ReplaceMenuSVG(element: HTMLElement, svg: string) {
|
||||
let item = element.firstChild as HTMLElement;
|
||||
item!.firstChild!.remove();
|
||||
|
||||
item.innerHTML = `<span>${item.innerHTML}</span>`;
|
||||
|
||||
let newsvg = stringToHTML(svg).firstChild;
|
||||
item.insertBefore(newsvg as Node, item.firstChild);
|
||||
}
|
||||
|
||||
const processedSymbol = Symbol("processed");
|
||||
|
||||
export async function ObserveMenuItemPosition() {
|
||||
if (isSeqtaEngageExperience()) return;
|
||||
await waitForElm("#menu > ul > li");
|
||||
|
||||
eventManager.register(
|
||||
"menuList",
|
||||
{
|
||||
parentElement: document.querySelector("#menu")!.firstChild as Element,
|
||||
},
|
||||
(element: Element) => {
|
||||
const node = element as HTMLElement;
|
||||
|
||||
// Only process top-level menu items and skip everything else
|
||||
if (
|
||||
!node.classList.contains("item") ||
|
||||
node.nodeName !== "LI" ||
|
||||
node.parentElement?.parentElement?.id !== "menu"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if already processed
|
||||
if ((element as any)[processedSymbol]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!MenuOptionsOpen) {
|
||||
const key =
|
||||
MenuitemSVGKey[node?.dataset?.key! as keyof typeof MenuitemSVGKey];
|
||||
if (key) {
|
||||
ReplaceMenuSVG(
|
||||
node,
|
||||
MenuitemSVGKey[node.dataset.key as keyof typeof MenuitemSVGKey],
|
||||
);
|
||||
} else if (node?.firstChild?.nodeName === "LABEL") {
|
||||
const label = node.firstChild as HTMLElement;
|
||||
let textNode = label.lastChild as HTMLElement;
|
||||
|
||||
if (
|
||||
textNode.nodeType === 3 &&
|
||||
textNode.parentNode &&
|
||||
textNode.parentNode.nodeName !== "SPAN"
|
||||
) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = textNode.nodeValue;
|
||||
|
||||
label.replaceChild(span, textNode);
|
||||
}
|
||||
}
|
||||
ChangeMenuItemPositions(settingsState.menuorder);
|
||||
|
||||
(element as any)[processedSymbol] = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function showConflictPopup() {
|
||||
if (document.getElementById("conflict-popup")) return;
|
||||
document.body.classList.remove("hidden");
|
||||
@@ -760,7 +687,7 @@ export function init() {
|
||||
}
|
||||
|
||||
document.querySelector(".legacy-root")?.classList.add("hidden");
|
||||
ObserveMenuItemPosition();
|
||||
void observeMenuItemPosition();
|
||||
|
||||
new StorageChangeHandler();
|
||||
new MessageHandler();
|
||||
|
||||
Reference in New Issue
Block a user