mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
feat: add confetti and render overview with svelte
This commit is contained in:
@@ -75,6 +75,7 @@
|
||||
"@uiw/codemirror-extensions-color": "^4.23.10",
|
||||
"@uiw/codemirror-theme-github": "^4.23.10",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"codemirror": "^6.0.1",
|
||||
"color": "^5.0.0",
|
||||
"dompurify": "^3.2.4",
|
||||
|
||||
@@ -15,9 +15,11 @@ export default function renderSvelte(
|
||||
},
|
||||
});
|
||||
|
||||
if (mountPoint instanceof ShadowRoot) {
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.textContent = style;
|
||||
mountPoint.appendChild(styleElement);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
import { determineStatus, formatDate, getGradeValue } from "./utils";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
export let data: any;
|
||||
|
||||
interface FilterOptions {
|
||||
subject: string;
|
||||
sortBy: "due" | "grade" | "subject" | "title";
|
||||
}
|
||||
|
||||
function percentageToLetter(percentage: number): string {
|
||||
const letterMap: Record<number, string> = {
|
||||
100: "A+",
|
||||
95: "A",
|
||||
90: "A-",
|
||||
85: "B+",
|
||||
80: "B",
|
||||
75: "B-",
|
||||
70: "C+",
|
||||
65: "C",
|
||||
60: "C-",
|
||||
55: "D+",
|
||||
50: "D",
|
||||
45: "D-",
|
||||
40: "E+",
|
||||
35: "E",
|
||||
30: "E-",
|
||||
0: "F",
|
||||
};
|
||||
|
||||
const rounded = Math.ceil(percentage / 5) * 5;
|
||||
return letterMap[rounded] || "F";
|
||||
}
|
||||
|
||||
let currentFilters: FilterOptions = {
|
||||
subject: "all",
|
||||
sortBy: "due",
|
||||
};
|
||||
|
||||
let filteredAssessments: any[] = [];
|
||||
let statusGroups: Record<string, any[]> = {};
|
||||
|
||||
function updateAssessments() {
|
||||
filteredAssessments = data.assessments.filter((a: any) => {
|
||||
const subjectMatch =
|
||||
currentFilters.subject === "all" || a.code === currentFilters.subject;
|
||||
return subjectMatch;
|
||||
});
|
||||
|
||||
filteredAssessments.sort((a: any, b: any) => {
|
||||
switch (currentFilters.sortBy) {
|
||||
case "due":
|
||||
return new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||
case "grade":
|
||||
const gradeA = getGradeValue(a);
|
||||
const gradeB = getGradeValue(b);
|
||||
if (gradeA === null && gradeB === null) return 0;
|
||||
if (gradeA === null) return 1;
|
||||
if (gradeB === null) return -1;
|
||||
return gradeB - gradeA;
|
||||
case "subject":
|
||||
return a.code.localeCompare(b.code);
|
||||
case "title":
|
||||
return a.title.localeCompare(b.title);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
statusGroups = {
|
||||
UPCOMING: [],
|
||||
DUE_SOON: [],
|
||||
OVERDUE: [],
|
||||
SUBMITTED: [],
|
||||
MARKS_RELEASED: [],
|
||||
};
|
||||
|
||||
filteredAssessments.forEach((assessment) => {
|
||||
const status = determineStatus(assessment);
|
||||
if (statusGroups[status]) {
|
||||
statusGroups[status].push(assessment);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDueDateClass(assessment: any): string {
|
||||
const status = determineStatus(assessment);
|
||||
switch (status) {
|
||||
case "OVERDUE":
|
||||
return "overdue";
|
||||
case "DUE_SOON":
|
||||
return "due-soon";
|
||||
case "UPCOMING":
|
||||
return "upcoming";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function markAssessmentCompleted(assessment: any) {
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
|
||||
if (!completed.includes(assessment.id)) {
|
||||
completed.push(assessment.id);
|
||||
localStorage.setItem(completedKey, JSON.stringify(completed));
|
||||
updateAssessments();
|
||||
checkForCelebration();
|
||||
}
|
||||
}
|
||||
|
||||
function unmarkAssessmentCompleted(assessment: any) {
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
|
||||
const index = completed.indexOf(assessment.id);
|
||||
if (index > -1) {
|
||||
completed.splice(index, 1);
|
||||
localStorage.setItem(completedKey, JSON.stringify(completed));
|
||||
updateAssessments();
|
||||
}
|
||||
}
|
||||
|
||||
function checkForCelebration() {
|
||||
const overdueCount = statusGroups.OVERDUE?.length || 0;
|
||||
const dueSoonCount = statusGroups.DUE_SOON?.length || 0;
|
||||
|
||||
if (overdueCount === 0 && dueSoonCount === 0) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const duration = 100;
|
||||
const end = Date.now() + duration;
|
||||
|
||||
(function frame() {
|
||||
confetti({
|
||||
particleCount: 17,
|
||||
angle: 60,
|
||||
spread: 65,
|
||||
drift: 0.8,
|
||||
startVelocity: 40,
|
||||
scalar: 2,
|
||||
gravity: 2,
|
||||
decay: 0.97,
|
||||
ticks: 300,
|
||||
origin: { x: 0, y: 1 },
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
|
||||
confetti({
|
||||
particleCount: 17,
|
||||
angle: 120,
|
||||
spread: 65,
|
||||
drift: -0.8,
|
||||
startVelocity: 40,
|
||||
scalar: 2,
|
||||
decay: 0.97,
|
||||
ticks: 300,
|
||||
gravity: 2,
|
||||
origin: { x: 1, y: 1 },
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
}());
|
||||
} catch (e) {
|
||||
console.log("Confetti celebration failed:", e);
|
||||
}
|
||||
}, 500);
|
||||
} else if (overdueCount === 0 || dueSoonCount === 0) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
confetti({
|
||||
particleCount: 100,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
scalar: 0.9,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log("Confetti celebration failed:", e);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function isManuallyCompleted(assessmentId: string): boolean {
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
return completed.includes(assessmentId);
|
||||
}
|
||||
|
||||
function handleCardClick(assessment: any, event: Event) {
|
||||
if ((event.target as HTMLElement).closest(".card-menu")) {
|
||||
return;
|
||||
}
|
||||
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
|
||||
}
|
||||
|
||||
let openMenuId: string | null = null;
|
||||
|
||||
function toggleMenu(assessmentId: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
openMenuId = openMenuId === assessmentId ? null : assessmentId;
|
||||
}
|
||||
|
||||
function closeAllMenus() {
|
||||
openMenuId = null;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (data) {
|
||||
updateAssessments();
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "UPCOMING",
|
||||
title: "Upcoming",
|
||||
className: "column-upcoming",
|
||||
icon: "📅",
|
||||
},
|
||||
{
|
||||
key: "DUE_SOON",
|
||||
title: "Due Soon",
|
||||
className: "column-due-soon",
|
||||
icon: "⏰",
|
||||
},
|
||||
{
|
||||
key: "OVERDUE",
|
||||
title: "Overdue",
|
||||
className: "column-overdue",
|
||||
icon: "🚨",
|
||||
},
|
||||
{
|
||||
key: "SUBMITTED",
|
||||
title: "Submitted",
|
||||
className: "column-submitted",
|
||||
icon: "📝",
|
||||
},
|
||||
{
|
||||
key: "MARKS_RELEASED",
|
||||
title: "Marked",
|
||||
className: "column-marked",
|
||||
icon: "✅",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeAllMenus} />
|
||||
|
||||
<div id="grid-view-container">
|
||||
<div class="grid-view-header">
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
<select class="filter-select" bind:value={currentFilters.subject}>
|
||||
<option value="all">All Subjects</option>
|
||||
{#each data.subjects as subject}
|
||||
<option value={subject.code}>{subject.code} - {subject.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="filter-select" bind:value={currentFilters.sortBy}>
|
||||
<option value="due">Sort by Due Date</option>
|
||||
<option value="grade">Sort by Grade</option>
|
||||
<option value="subject">Sort by Subject</option>
|
||||
<option value="title">Sort by Title</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main-grid-content">
|
||||
{#if filteredAssessments.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<p>No assessments found matching your filters</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="kanban-board">
|
||||
{#each columns as column}
|
||||
{#if statusGroups[column.key]?.length > 0}
|
||||
<div class="kanban-column-parent">
|
||||
<div class="kanban-column {column.className}">
|
||||
<div class="column-header">
|
||||
<div class="column-title">
|
||||
{column.icon} {column.title}
|
||||
<span class="column-count">{statusGroups[column.key].length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
|
||||
{#each statusGroups[column.key] as assessment}
|
||||
{@const status = determineStatus(assessment)}
|
||||
{@const dueDateClass = getDueDateClass(assessment)}
|
||||
{@const isCompleted = isManuallyCompleted(assessment.id)}
|
||||
{@const color = data.colors[assessment.code] || "#6366f1"}
|
||||
<div
|
||||
class="assessment-card"
|
||||
data-subject={assessment.code}
|
||||
data-status={status}
|
||||
style="--subject-color: {color}"
|
||||
on:click={(e) => handleCardClick(assessment, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={(e) => e.key === 'Enter' && handleCardClick(assessment, e)}
|
||||
>
|
||||
<div class="card-labels">
|
||||
<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>
|
||||
{/if}
|
||||
{#if isCompleted && status === "MARKS_RELEASED" && !assessment.results}
|
||||
<span class="card-label label-completed" style="background: #059669; color: white;">Completed</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if status !== "MARKS_RELEASED" || isCompleted}
|
||||
<div class="card-menu">
|
||||
<button
|
||||
class="menu-button"
|
||||
data-assessment-id={assessment.id}
|
||||
on:click={(e) => toggleMenu(assessment.id, e)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="menu-dropdown" style="display: {openMenuId === assessment.id ? 'block' : 'none'};">
|
||||
{#if status !== "MARKS_RELEASED"}
|
||||
<button class="menu-item mark-completed" on:click={() => markAssessmentCompleted(assessment)}>
|
||||
Mark as Completed
|
||||
</button>
|
||||
{:else if isCompleted}
|
||||
<button class="menu-item mark-not-completed" on:click={() => unmarkAssessmentCompleted(assessment)}>
|
||||
Mark as Not Complete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3 class="assessment-title">{assessment.title}</h3>
|
||||
|
||||
{#if !assessment.results && !isCompleted}
|
||||
<div class="assessment-meta">
|
||||
<div class="due-date {dueDateClass}">
|
||||
📅 {formatDate(assessment.due, assessment.submitted)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if assessment.results}
|
||||
<div class="card-footer">
|
||||
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: {color}">
|
||||
<div style="width: {assessment.results.percentage}%" class="Thermoscore__fill___ojxDI">
|
||||
<div title="{assessment.results.percentage}%" class="Thermoscore__text___XSR_M">
|
||||
{(() => {
|
||||
const allSettings = settingsState.getAll() as unknown as any;
|
||||
const letterGradeSetting = allSettings["plugin.assessments-average.settings"]?.lettergrade;
|
||||
return letterGradeSetting
|
||||
? percentageToLetter(assessment.results.percentage)
|
||||
: `${assessment.results.percentage}%`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
export let error: string;
|
||||
</script>
|
||||
|
||||
<div class="error-container">
|
||||
<p class="error-text">Failed to load assessments</p>
|
||||
<p style="color: #94a3b8; font-size: 0.875rem;">{error}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
<div id="grid-view-container">
|
||||
<div class="grid-view-header">
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
<select class="filter-select" disabled>
|
||||
<option value="all">Loading subjects...</option>
|
||||
</select>
|
||||
<select class="filter-select" disabled>
|
||||
<option value="due">Sort by Due Date</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main-grid-content">
|
||||
<div class="kanban-board">
|
||||
{#each columns as column}
|
||||
<div class="kanban-column-parent">
|
||||
<div class="kanban-column {column.className}">
|
||||
<div class="column-header">
|
||||
<div class="column-title">
|
||||
{column.icon} {column.title}
|
||||
<span class="column-count">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column-cards" id="{column.key.toLowerCase()}-cards">
|
||||
{#each Array(column.skeletonCount) as _}
|
||||
<div class="assessment-card">
|
||||
<div class="skeleton-element skeleton-label"></div>
|
||||
<div class="skeleton-element skeleton-title"></div>
|
||||
<div class="skeleton-element skeleton-title-line2"></div>
|
||||
<div class="skeleton-element skeleton-meta"></div>
|
||||
{#if column.key === "MARKS_RELEASED"}
|
||||
<div class="skeleton-footer">
|
||||
<div class="skeleton-element" style="height: 16px; width: 100%;"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script lang="ts">
|
||||
const columns = [
|
||||
{
|
||||
key: "UPCOMING",
|
||||
title: "Upcoming",
|
||||
className: "column-upcoming",
|
||||
icon: "📅",
|
||||
skeletonCount: 3,
|
||||
},
|
||||
{
|
||||
key: "DUE_SOON",
|
||||
title: "Due Soon",
|
||||
className: "column-due-soon",
|
||||
icon: "⏰",
|
||||
skeletonCount: 2,
|
||||
},
|
||||
{
|
||||
key: "OVERDUE",
|
||||
title: "Overdue",
|
||||
className: "column-overdue",
|
||||
icon: "🚨",
|
||||
skeletonCount: 1,
|
||||
},
|
||||
{
|
||||
key: "MARKS_RELEASED",
|
||||
title: "Marked",
|
||||
className: "column-marked",
|
||||
icon: "✅",
|
||||
skeletonCount: 4,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
@@ -1,619 +1,45 @@
|
||||
import { determineStatus, formatDate, getGradeValue } from "./utils";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import renderSvelte from "@/interface/main";
|
||||
import AssessmentsOverview from "./AssessmentsOverview.svelte";
|
||||
import SkeletonLoader from "./SkeletonLoader.svelte";
|
||||
import ErrorState from "./ErrorState.svelte";
|
||||
import { unmount } from "svelte";
|
||||
|
||||
function percentageToLetter(percentage: number): string {
|
||||
const letterMap: Record<number, string> = {
|
||||
100: "A+",
|
||||
95: "A",
|
||||
90: "A-",
|
||||
85: "B+",
|
||||
80: "B",
|
||||
75: "B-",
|
||||
70: "C+",
|
||||
65: "C",
|
||||
60: "C-",
|
||||
55: "D+",
|
||||
50: "D",
|
||||
45: "D-",
|
||||
40: "E+",
|
||||
35: "E",
|
||||
30: "E-",
|
||||
0: "F",
|
||||
};
|
||||
|
||||
const rounded = Math.ceil(percentage / 5) * 5;
|
||||
return letterMap[rounded] || "F";
|
||||
}
|
||||
|
||||
interface FilterOptions {
|
||||
subject: string;
|
||||
sortBy: "due" | "grade" | "subject" | "title";
|
||||
}
|
||||
|
||||
let currentFilters: FilterOptions = {
|
||||
subject: "all",
|
||||
sortBy: "due",
|
||||
};
|
||||
let currentApp: any = null;
|
||||
|
||||
export function renderGrid(container: HTMLElement, data: any) {
|
||||
if (currentApp) {
|
||||
unmount(currentApp);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
container.className = "";
|
||||
container.id = "grid-view-container";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "grid-view-header";
|
||||
header.innerHTML = `
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
<select class="filter-select" id="subject-filter">
|
||||
<option value="all">All Subjects</option>
|
||||
${data.subjects.map((s: any) => `<option value="${s.code}">${s.code} - ${s.title}</option>`).join("")}
|
||||
</select>
|
||||
<select class="filter-select" id="sort-filter">
|
||||
<option value="due">Sort by Due Date</option>
|
||||
<option value="grade">Sort by Grade</option>
|
||||
<option value="subject">Sort by Subject</option>
|
||||
<option value="title">Sort by Title</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(header);
|
||||
|
||||
const subjectFilter = header.querySelector(
|
||||
"#subject-filter",
|
||||
) as HTMLSelectElement;
|
||||
const sortFilter = header.querySelector("#sort-filter") as HTMLSelectElement;
|
||||
|
||||
subjectFilter.addEventListener("change", () => {
|
||||
currentFilters.subject = subjectFilter.value;
|
||||
renderAssessments();
|
||||
});
|
||||
|
||||
sortFilter.addEventListener("change", () => {
|
||||
currentFilters.sortBy = sortFilter.value as any;
|
||||
renderAssessments();
|
||||
});
|
||||
|
||||
const mainContent = document.createElement("div");
|
||||
mainContent.id = "main-grid-content";
|
||||
container.appendChild(mainContent);
|
||||
|
||||
function renderAssessments() {
|
||||
const contentArea = container.querySelector(
|
||||
"#main-grid-content",
|
||||
) as HTMLElement;
|
||||
contentArea.innerHTML = "";
|
||||
|
||||
let filteredAssessments = data.assessments.filter((a: any) => {
|
||||
const subjectMatch =
|
||||
currentFilters.subject === "all" || a.code === currentFilters.subject;
|
||||
return subjectMatch;
|
||||
});
|
||||
|
||||
filteredAssessments.sort((a: any, b: any) => {
|
||||
switch (currentFilters.sortBy) {
|
||||
case "due":
|
||||
return new Date(a.due).getTime() - new Date(b.due).getTime();
|
||||
case "grade":
|
||||
const gradeA = getGradeValue(a);
|
||||
const gradeB = getGradeValue(b);
|
||||
if (gradeA === null && gradeB === null) return 0;
|
||||
if (gradeA === null) return 1;
|
||||
if (gradeB === null) return -1;
|
||||
return gradeB - gradeA;
|
||||
case "subject":
|
||||
return a.code.localeCompare(b.code);
|
||||
case "title":
|
||||
return a.title.localeCompare(b.title);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (filteredAssessments.length === 0) {
|
||||
contentArea.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<p>No assessments found matching your filters</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
renderKanbanBoard(contentArea, filteredAssessments, data);
|
||||
}
|
||||
|
||||
function renderKanbanBoard(
|
||||
container: HTMLElement,
|
||||
assessments: any[],
|
||||
data: any,
|
||||
) {
|
||||
const statusGroups = {
|
||||
UPCOMING: [] as any[],
|
||||
DUE_SOON: [] as any[],
|
||||
OVERDUE: [] as any[],
|
||||
SUBMITTED: [] as any[],
|
||||
MARKS_RELEASED: [] as any[],
|
||||
};
|
||||
|
||||
assessments.forEach((assessment) => {
|
||||
const status = determineStatus(assessment);
|
||||
if (statusGroups[status as keyof typeof statusGroups]) {
|
||||
statusGroups[status as keyof typeof statusGroups].push(assessment);
|
||||
}
|
||||
});
|
||||
|
||||
const board = document.createElement("div");
|
||||
board.className = "kanban-board";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "UPCOMING",
|
||||
title: "Upcoming",
|
||||
className: "column-upcoming",
|
||||
icon: "📅",
|
||||
},
|
||||
{
|
||||
key: "DUE_SOON",
|
||||
title: "Due Soon",
|
||||
className: "column-due-soon",
|
||||
icon: "⏰",
|
||||
},
|
||||
{
|
||||
key: "OVERDUE",
|
||||
title: "Overdue",
|
||||
className: "column-overdue",
|
||||
icon: "🚨",
|
||||
},
|
||||
{
|
||||
key: "SUBMITTED",
|
||||
title: "Submitted",
|
||||
className: "column-submitted",
|
||||
icon: "📝",
|
||||
},
|
||||
{
|
||||
key: "MARKS_RELEASED",
|
||||
title: "Marked",
|
||||
className: "column-marked",
|
||||
icon: "✅",
|
||||
},
|
||||
];
|
||||
|
||||
columns.forEach((column) => {
|
||||
const assessmentList =
|
||||
statusGroups[column.key as keyof typeof statusGroups];
|
||||
|
||||
// Skip rendering empty columns
|
||||
if (assessmentList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnParentEl = document.createElement("div");
|
||||
columnParentEl.className = "kanban-column-parent";
|
||||
|
||||
const columnEl = document.createElement("div");
|
||||
columnEl.className = `kanban-column ${column.className}`;
|
||||
|
||||
columnEl.innerHTML = `
|
||||
<div class="column-header">
|
||||
<div class="column-title">
|
||||
${column.icon} ${column.title}
|
||||
<span class="column-count">${assessmentList.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column-cards" id="${column.key.toLowerCase()}-cards"></div>
|
||||
`;
|
||||
|
||||
const cardsContainer = columnEl.querySelector(
|
||||
`#${column.key.toLowerCase()}-cards`,
|
||||
) as HTMLElement;
|
||||
|
||||
assessmentList.forEach((assessment) => {
|
||||
cardsContainer.appendChild(
|
||||
createKanbanCard(
|
||||
assessment,
|
||||
data.colors[assessment.code] || "#6366f1",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
columnParentEl.appendChild(columnEl);
|
||||
board.appendChild(columnParentEl);
|
||||
});
|
||||
|
||||
container.appendChild(board);
|
||||
}
|
||||
|
||||
function createKanbanCard(assessment: any, color: string): HTMLElement {
|
||||
const status = determineStatus(assessment);
|
||||
const dueDateClass = getDueDateClass(assessment);
|
||||
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
const isManuallyCompleted = completed.includes(assessment.id);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "assessment-card";
|
||||
card.dataset.subject = assessment.code;
|
||||
card.dataset.status = status;
|
||||
card.style.setProperty("--subject-color", color);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-labels">
|
||||
<span class="card-label label-subject">${assessment.code}</span>
|
||||
${assessment.submitted ? '<span class="card-label label-submitted" style="background: #10b981; color: white;">Submitted</span>' : ""}
|
||||
${isManuallyCompleted && status === "MARKS_RELEASED" && !assessment.results ? '<span class="card-label label-completed" style="background: #059669; color: white;">Completed</span>' : ""}
|
||||
</div>
|
||||
${
|
||||
status !== "MARKS_RELEASED"
|
||||
? `
|
||||
<div class="card-menu">
|
||||
<button class="menu-button" data-assessment-id="${assessment.id}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="menu-dropdown" style="display: none;">
|
||||
<button class="menu-item mark-completed">Mark as Completed</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: isManuallyCompleted
|
||||
? `
|
||||
<div class="card-menu">
|
||||
<button class="menu-button" data-assessment-id="${assessment.id}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="menu-dropdown" style="display: none;">
|
||||
<button class="menu-item mark-not-completed">Mark as Not Complete</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<h3 class="assessment-title">${assessment.title}</h3>
|
||||
${
|
||||
!assessment.results && !isManuallyCompleted
|
||||
? `
|
||||
<div class="assessment-meta">
|
||||
<div class="due-date ${dueDateClass}">
|
||||
📅 ${formatDate(assessment.due, assessment.submitted)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
assessment.results
|
||||
? `
|
||||
<div class="card-footer">
|
||||
<div class="Thermoscore__Thermoscore___WFpL3" style="--fill-colour: ${color}">
|
||||
<div style="width: ${assessment.results.percentage}%" class="Thermoscore__fill___ojxDI">
|
||||
<div title="${assessment.results.percentage}%" class="Thermoscore__text___XSR_M">
|
||||
${(() => {
|
||||
const allSettings = settingsState.getAll() as unknown as any;
|
||||
const letterGradeSetting =
|
||||
allSettings["plugin.assessments-average.settings"]
|
||||
?.lettergrade;
|
||||
return letterGradeSetting
|
||||
? percentageToLetter(assessment.results.percentage)
|
||||
: `${assessment.results.percentage}%`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
|
||||
card.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".card-menu")) {
|
||||
return;
|
||||
}
|
||||
window.location.hash = `#?page=/assessments/${assessment.programmeID}:${assessment.metaclassID}&item=${assessment.id}`;
|
||||
});
|
||||
|
||||
if (status !== "MARKS_RELEASED" || isManuallyCompleted) {
|
||||
const menuButton = card.querySelector(
|
||||
".menu-button",
|
||||
) as HTMLButtonElement;
|
||||
const menuDropdown = card.querySelector(".menu-dropdown") as HTMLElement;
|
||||
const markCompletedBtn = card.querySelector(
|
||||
".mark-completed",
|
||||
) as HTMLButtonElement;
|
||||
const markNotCompletedBtn = card.querySelector(
|
||||
".mark-not-completed",
|
||||
) as HTMLButtonElement;
|
||||
|
||||
menuButton?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
document.querySelectorAll(".menu-dropdown").forEach((dropdown) => {
|
||||
if (dropdown !== menuDropdown) {
|
||||
(dropdown as HTMLElement).style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
menuDropdown.style.display =
|
||||
menuDropdown.style.display === "none" ? "block" : "none";
|
||||
});
|
||||
|
||||
markCompletedBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
markAssessmentCompleted(assessment);
|
||||
menuDropdown.style.display = "none";
|
||||
});
|
||||
|
||||
markNotCompletedBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
unmarkAssessmentCompleted(assessment);
|
||||
menuDropdown.style.display = "none";
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!card.contains(e.target as Node)) {
|
||||
menuDropdown.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function markAssessmentCompleted(assessment: any) {
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
|
||||
if (!completed.includes(assessment.id)) {
|
||||
completed.push(assessment.id);
|
||||
localStorage.setItem(completedKey, JSON.stringify(completed));
|
||||
|
||||
updateAssessmentCard(assessment);
|
||||
}
|
||||
}
|
||||
|
||||
function unmarkAssessmentCompleted(assessment: any) {
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
|
||||
const index = completed.indexOf(assessment.id);
|
||||
if (index > -1) {
|
||||
completed.splice(index, 1);
|
||||
localStorage.setItem(completedKey, JSON.stringify(completed));
|
||||
|
||||
updateAssessmentCard(assessment);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssessmentCard(assessment: any) {
|
||||
const existingCard = document
|
||||
.querySelector(`[data-assessment-id="${assessment.id}"]`)
|
||||
?.closest(".assessment-card") as HTMLElement;
|
||||
if (!existingCard) return;
|
||||
|
||||
const newStatus = determineStatus(assessment);
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
const isManuallyCompleted = completed.includes(assessment.id);
|
||||
|
||||
const currentColumn = existingCard.closest(".column-cards") as HTMLElement;
|
||||
const currentColumnId = currentColumn?.id;
|
||||
const targetColumnId = `${newStatus.toLowerCase()}-cards`;
|
||||
|
||||
if (currentColumnId !== targetColumnId) {
|
||||
const targetColumn = document.getElementById(targetColumnId);
|
||||
if (targetColumn) {
|
||||
existingCard.remove();
|
||||
|
||||
const newCard = createKanbanCard(
|
||||
assessment,
|
||||
data.colors[assessment.code] || "#6366f1",
|
||||
);
|
||||
targetColumn.appendChild(newCard);
|
||||
|
||||
updateColumnCounts();
|
||||
|
||||
const emptyState = targetColumn.querySelector(".empty-column");
|
||||
if (emptyState) {
|
||||
emptyState.remove();
|
||||
}
|
||||
|
||||
if (currentColumn && currentColumn.children.length === 0) {
|
||||
const columnKey = currentColumnId
|
||||
?.replace("-cards", "")
|
||||
.toUpperCase();
|
||||
const columnInfo = getColumnInfo(columnKey);
|
||||
if (columnInfo) {
|
||||
currentColumn.innerHTML = `
|
||||
<div class="empty-column">
|
||||
<div class="empty-icon">${columnInfo.icon}</div>
|
||||
<p>No ${columnInfo.title.toLowerCase()} assessments</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newCard = createKanbanCard(
|
||||
assessment,
|
||||
data.colors[assessment.code] || "#6366f1",
|
||||
);
|
||||
existingCard.replaceWith(newCard);
|
||||
}
|
||||
}
|
||||
|
||||
function updateColumnCounts() {
|
||||
document.querySelectorAll(".column-count").forEach((countEl) => {
|
||||
const column = countEl.closest(".kanban-column");
|
||||
const cardsContainer = column?.querySelector(".column-cards");
|
||||
const cardCount =
|
||||
cardsContainer?.querySelectorAll(".assessment-card").length || 0;
|
||||
countEl.textContent = cardCount.toString();
|
||||
});
|
||||
}
|
||||
|
||||
function getColumnInfo(columnKey: string | undefined) {
|
||||
const columns = {
|
||||
UPCOMING: { title: "Upcoming", icon: "📅" },
|
||||
DUE_SOON: { title: "Due Soon", icon: "⏰" },
|
||||
OVERDUE: { title: "Overdue", icon: "🚨" },
|
||||
SUBMITTED: { title: "Submitted", icon: "📝" },
|
||||
MARKS_RELEASED: { title: "Marked", icon: "✅" },
|
||||
};
|
||||
return columnKey ? columns[columnKey as keyof typeof columns] : null;
|
||||
}
|
||||
|
||||
function getDueDateClass(assessment: any): string {
|
||||
const status = determineStatus(assessment);
|
||||
switch (status) {
|
||||
case "OVERDUE":
|
||||
return "overdue";
|
||||
case "DUE_SOON":
|
||||
return "due-soon";
|
||||
case "UPCOMING":
|
||||
return "upcoming";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
renderAssessments();
|
||||
currentApp = renderSvelte(AssessmentsOverview, container, { data });
|
||||
}
|
||||
|
||||
export function renderSkeletonLoader(container: HTMLElement) {
|
||||
if (currentApp) {
|
||||
unmount(currentApp);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
container.className = "";
|
||||
container.id = "grid-view-container";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "grid-view-header";
|
||||
header.innerHTML = `
|
||||
<h1 class="grid-view-title">Assessments</h1>
|
||||
<div class="grid-view-filters">
|
||||
<select class="filter-select" id="subject-filter" disabled>
|
||||
<option value="all">Loading subjects...</option>
|
||||
</select>
|
||||
<select class="filter-select" id="sort-filter" disabled>
|
||||
<option value="due">Sort by Due Date</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(header);
|
||||
|
||||
const mainContent = document.createElement("div");
|
||||
mainContent.id = "main-grid-content";
|
||||
container.appendChild(mainContent);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "UPCOMING",
|
||||
title: "Upcoming",
|
||||
className: "column-upcoming",
|
||||
icon: "📅",
|
||||
skeletonCount: 3,
|
||||
},
|
||||
{
|
||||
key: "DUE_SOON",
|
||||
title: "Due Soon",
|
||||
className: "column-due-soon",
|
||||
icon: "⏰",
|
||||
skeletonCount: 2,
|
||||
},
|
||||
{
|
||||
key: "OVERDUE",
|
||||
title: "Overdue",
|
||||
className: "column-overdue",
|
||||
icon: "🚨",
|
||||
skeletonCount: 1,
|
||||
},
|
||||
{
|
||||
key: "MARKS_RELEASED",
|
||||
title: "Marked",
|
||||
className: "column-marked",
|
||||
icon: "✅",
|
||||
skeletonCount: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const board = document.createElement("div");
|
||||
board.className = "kanban-board";
|
||||
|
||||
columns.forEach((column) => {
|
||||
const columnParentEl = document.createElement("div");
|
||||
columnParentEl.className = "kanban-column-parent";
|
||||
|
||||
const columnEl = document.createElement("div");
|
||||
columnEl.className = `kanban-column ${column.className}`;
|
||||
|
||||
columnEl.innerHTML = `
|
||||
<div class="column-header">
|
||||
<div class="column-title">
|
||||
${column.icon} ${column.title}
|
||||
<span class="column-count">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column-cards" id="${column.key.toLowerCase()}-cards"></div>
|
||||
`;
|
||||
|
||||
const cardsContainer = columnEl.querySelector(
|
||||
`#${column.key.toLowerCase()}-cards`,
|
||||
) as HTMLElement;
|
||||
|
||||
for (let i = 0; i < column.skeletonCount; i++) {
|
||||
cardsContainer.appendChild(
|
||||
createSkeletonCard(column.key === "MARKS_RELEASED"),
|
||||
);
|
||||
}
|
||||
|
||||
columnParentEl.appendChild(columnEl);
|
||||
board.appendChild(columnParentEl);
|
||||
});
|
||||
|
||||
mainContent.appendChild(board);
|
||||
currentApp = renderSvelte(SkeletonLoader, container);
|
||||
}
|
||||
|
||||
function createSkeletonCard(footer: boolean = false): HTMLElement {
|
||||
const card = document.createElement("div");
|
||||
card.className = "assessment-card";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="skeleton-element skeleton-label"></div>
|
||||
<div class="skeleton-element skeleton-title"></div>
|
||||
<div class="skeleton-element skeleton-title-line2"></div>
|
||||
<div class="skeleton-element skeleton-meta"></div>
|
||||
${
|
||||
footer
|
||||
? `
|
||||
<div class="skeleton-footer">
|
||||
<div class="skeleton-element" style="height: 16px; width: 100%;"></div>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
export function renderLoadingState(container: HTMLElement) {
|
||||
renderSkeletonLoader(container);
|
||||
}
|
||||
|
||||
export function renderErrorState(container: HTMLElement, error: string) {
|
||||
container.innerHTML = `
|
||||
<div class="error-container">
|
||||
<p class="error-text">Failed to load assessments</p>
|
||||
<p style="color: #94a3b8; font-size: 0.875rem;">${error}</p>
|
||||
</div>
|
||||
`;
|
||||
if (currentApp) {
|
||||
unmount(currentApp);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
container.className = "";
|
||||
|
||||
currentApp = renderSvelte(ErrorState, container, { error });
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module 'canvas-confetti' {
|
||||
const confetti: any;
|
||||
export default confetti;
|
||||
}
|
||||
Reference in New Issue
Block a user