CanvasRefined/js/popup.js
2026-10-18 16:57:19 -07:00

3602 lines
155 KiB
JavaScript

const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'focus_timer', 'breathing', 'daily_quotes', 'better_todo', 'todo_celebrations_enabled', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics', 'hide_personal_details'];
const syncedSubOptions = [
"grade_analytics_zones",
"hide_course_images",
"todo_hide_feedback",
"todo_hide_read",
"todo_full_height",
"todo_celebrations",
"todo_celebration_amount",
"todo_progress_rings",
"todo_timeframe",
"todo_timeframe_custom_past",
"todo_timeframe_custom_future",
"device_dark",
"relative_dues",
"card_overdues",
"hide_completed_cards",
"equal_height_cards",
// "todo_overdues",
"gpa_calc_prepend",
"gpa_calc_cumulative",
"gpa_calc_weighted",
"focus_timer_position",
"focus_timer_preset_1",
"focus_timer_preset_2",
"focus_timer_preset_3",
"breathing_position",
"breathing_preset_1",
"breathing_preset_2",
"breathing_preset_3",
"daily_quotes_position",
"widget_row_height",
"dashboard_notes_height",
"gpa_calc_height",
"focus_timer_height",
"breathing_height",
"daily_quotes_height",
"auto_dark",
"auto_dark_start",
"auto_dark_end",
"custom_font_skip_p",
"num_assignments",
"assignment_date_format",
"todo_hr24",
"todo_separate_scrollbar",
"todo_alternate_colors",
"todo_ignore_card_colors",
"todo_remove_icons",
"todo_show_scores",
"grade_hover",
"card_letter",
"auto_detect_disabled",
// "hide_completed",
"num_todo_items",
"hover_preview",
"customCardStyles",
"imageSize",
"cardRoundness",
"imageRoundness",
"cardSpacing",
"cardWidth",
"cardHeight",
"cardPadding",
// Card Grid settings — included here so theme exports, saved themes and
// previous-theme snapshots actually read them from storage (getExport
// drops keys it wasn't given).
"card_grid",
"card_grid_columns",
"card_grid_rows",
"card_grid_column_gap",
"card_grid_row_gap",
"card_grid_center_rows",
"card_grid_flex",
"customBackgroundLink",
"customBackgroundScale",
"customBackgroundDaily",
"customBackgroundNasaDaily",
"fitImageToScreen",
"sidebar_scale",
"bg_opacity",
"sidebar_opacity",
"bg_blur",
"sidebar_blur",
"card_opacity",
"card_blur",
];
const localSwitches = [];
// Theme export only carries visual settings, never personal productivity data.
const exportDarkSchedule = ["auto_dark", "auto_dark_start", "auto_dark_end", "device_dark"];
const exportCardColorToggles = ["gradient_cards", "disable_color_overlay"];
const exportCardStyles = ["customCardStyles", "imageSize", "cardRoundness", "imageRoundness", "cardSpacing", "cardWidth", "cardHeight", "cardPadding", "card_grid", "card_grid_columns", "card_grid_rows", "card_grid_column_gap", "card_grid_row_gap", "card_grid_center_rows", "card_grid_flex"];
const exportLayout = ["full_width", "center_cards", "condensed_cards", "equal_height_cards", "remlogo", "hide_new_canvas", "hide_navbar", "tab_icons"];
const exportSidebar = ["better_sidebar", "sidebar_scale"];
const exportTodo = ["better_todo", "todo_hide_feedback", "todo_hide_read", "todo_full_height", "todo_celebrations_enabled", "todo_celebrations", "todo_celebration_amount", "todo_progress_rings", "todo_timeframe", "todo_timeframe_custom_past", "todo_timeframe_custom_future", "todo_hr24", "todo_separate_scrollbar", "todo_alternate_colors", "todo_ignore_card_colors", "todo_remove_icons", "todo_show_scores", "hover_preview"];
const exportGpa = ["gpa_calc", "gpa_calc_prepend", "gpa_calc_cumulative", "gpa_calc_weighted"];
const exportBackground = ["customBackgroundLink", "customBackgroundScale", "customBackgroundDaily", "customBackgroundNasaDaily", "fitImageToScreen", "card_transparency", "bg_opacity", "sidebar_opacity", "bg_blur", "sidebar_blur", "card_opacity", "card_blur"];
// Master "On/off toggles" = every visual toggle (no GPA, no dark-mode schedule,
// no personal productivity features). Includes the former separate Layout and
// Sidebar groups, plus Grade Analytics and its colored-zones sub-toggle.
const exportToggles = ["dark_mode", "quiz_safe_mode", "grade_analytics", "grade_analytics_zones"].concat(exportCardColorToggles, exportLayout, exportSidebar, exportTodo);
const fontsDropdownStateKey = "fonts_dropdown_open";
const apiurl = "none";
const defaultOptions = {
"local": {
"previous_colors": null,
"previous_theme": null,
"errors": [],
},
"sync": {
"dark_preset": {
"background-0": "#161616",
"background-1": "#1e1e1e",
"background-2": "#262626",
"borders": "#3c3c3c",
"text-0": "#f5f5f5",
"text-1": "#e2e2e2",
"text-2": "#ababab",
"links": "#56Caf0",
"sidebar": "#1e1e1e",
"sidebar-text": "#f5f5f5"
},
"new_install": true,
"assignments_due": true,
"hide_personal_details": false,
"hide_course_images": false,
"gpa_calc": false,
"dark_mode": true,
"gradent_cards": false,
"disable_color_overlay": false,
"auto_dark": false,
"auto_dark_start": { "hour": "20", "minute": "00" },
"auto_dark_end": { "hour": "08", "minute": "00" },
"num_assignments": 4,
"custom_domain": [""],
"custom_domain_denied": [],
"auto_detect_disabled": false,
"assignments_done": [],
"dashboard_grades": false,
"assignment_date_format": false,
"dashboard_notes": false,
"dashboard_notes_text": "",
"dashboard_notes_mode": "edit",
"dashboard_notes_position": "top",
"gpa_calc_position": "cards_end",
"focus_timer": false,
"focus_timer_position": "cards_end",
"focus_timer_preset_1": { "h": 0, "m": 15, "s": 0 },
"focus_timer_preset_2": { "h": 0, "m": 25, "s": 0 },
"focus_timer_preset_3": { "h": 0, "m": 45, "s": 0 },
"breathing": false,
"breathing_position": "cards_end",
"breathing_preset_1": { "h": 0, "m": 3, "s": 0 },
"breathing_preset_2": { "h": 0, "m": 5, "s": 0 },
"breathing_preset_3": { "h": 0, "m": 10, "s": 0 },
"daily_quotes": false,
"daily_quotes_position": "top",
// Height settings (px; 0 = auto). widget_row_height applies to the
// shared Top/Bottom row; *_height applies to a widget in Before/After.
"widget_row_height": 0,
"dashboard_notes_height": 0,
"gpa_calc_height": 0,
"focus_timer_height": 0,
"breathing_height": 0,
"daily_quotes_height": 0,
"better_todo": true,
"better_sidebar": false,
"sidebar_scale": 100,
"bg_opacity": 65,
"sidebar_opacity": 100,
"bg_blur": 8,
"sidebar_blur": 0,
"card_transparency": false,
"card_opacity": 80,
"card_blur": 8,
"todo_hr24": false,
"anon_grade_level": "high",
"anon_grade_seed": 0,
"anon_fake_todo": false,
"anon_fake_completed": 2,
"anon_progress_enabled": false,
"anon_progress_done": 12,
"anon_progress_total": 18,
"todo_separate_scrollbar": false,
"todo_alternate_colors": false,
"todo_ignore_card_colors": false,
"todo_remove_icons": false,
"todo_show_scores": false,
"todo_hide_read": true,
"condensed_cards": false,
"center_cards": false,
"custom_cards": {},
"custom_cards_2": {},
"custom_cards_3": {},
"custom_assignments": [],
"custom_assignments_overflow": ["custom_assignments"],
"grade_hover": false,
"card_letter": true,
// "hide_completed": false,
"num_todo_items": 10,
"custom_font": { "link": "", "family": "" },
"custom_font_skip_p": false,
"hover_preview": true,
"full_width": null,
"remlogo": null,
"gpa_calc_bounds": {
"A+": { "cutoff": 97, "gpa": 4.0 },
"A": { "cutoff": 93, "gpa": 4 },
"A-": { "cutoff": 90, "gpa": 3.7 },
"B+": { "cutoff": 87, "gpa": 3.3 },
"B": { "cutoff": 83, "gpa": 3 },
"B-": { "cutoff": 80, "gpa": 2.7 },
"C+": { "cutoff": 77, "gpa": 2.3 },
"C": { "cutoff": 73, "gpa": 2 },
"C-": { "cutoff": 70, "gpa": 1.7 },
"D+": { "cutoff": 67, "gpa": 1.3 },
"D": { "cutoff": 63, "gpa": 1 },
"D-": { "cutoff": 60, "gpa": .7 },
"F": { "cutoff": 0, "gpa": 0 }
},
// "todo_overdues": false,
"card_overdues": false,
"hide_completed_cards": false,
"relative_dues": false,
"equal_height_cards": false,
"hide_new_canvas": true,
"hide_sequence_footer": false,
"hide_navbar": false,
"grade_analytics_zones": false,
"quiz_safe_mode": false,
"dark_mode_fix": [],
"assignment_states": {},
"tab_icons": false,
"todo_hide_feedback": false,
"todo_full_height": false,
"todo_progress_rings": "rings",
"todo_timeframe": "all",
"todo_timeframe_custom_past": 7,
"todo_timeframe_custom_future": 14,
"todo_more_expanded": false,
"todo_celebrations_enabled": true,
"todo_celebrations": ["confetti"],
"todo_celebration_amount": "normal",
"device_dark": false,
"cumulative_gpa": { "name": "Cumulative GPA", "hidden": false, "weight": "dnc", "credits": 999, "gr": 3.21 },
// "show_updates": false,
"card_method_date": false,
"card_method_dashboard": true,
"card_limit": 25,
"imageSize": 100,
"cardRoundness": 5,
"imageRoundness": 0,
"cardSpacing": 0,
"cardWidth": 262,
"cardHeight": 146,
"cardPadding": 0,
"card_grid": false,
"card_grid_columns": 4,
"card_grid_rows": 3,
"card_grid_column_gap": 12,
"card_grid_row_gap": 12,
"card_grid_center_rows": false,
"card_grid_flex": false,
"customCardStyles": false,
"customBackgroundLink": "",
"customBackgroundScale": 100,
"customBackgroundDaily": false,
"customBackgroundNasaDaily": false,
"fitImageToScreen": false,
}
};
sendFromPopup("getCards");
// refresh the cards if new ones were just recieved
chrome.storage.onChanged.addListener((changes) => {
if (changes["custom_cards"]) {
if (Object.keys(changes["custom_cards"].oldValue || {}).length !== Object.keys(changes["custom_cards"].newValue || {}).length) {
displayAdvancedCards();
}
// Hidden-card edits from the card menu change how many cells the
// grid preview should mark as filled.
updateCardGridPreview();
scheduleGridPreviewFollowUp();
}
});
// The live card count is read from the dashboard tab's DOM, which applies
// display:none for a newly hidden card in its own storage listener — a race
// against the popup's. One debounced follow-up render re-syncs the preview
// (and the cut-off warning) right after a card edit settles.
let gridPreviewFollowUpTimer = null;
function scheduleGridPreviewFollowUp() {
if (gridPreviewFollowUpTimer) clearTimeout(gridPreviewFollowUpTimer);
gridPreviewFollowUpTimer = setTimeout(() => {
gridPreviewFollowUpTimer = null;
updateCardGridPreview();
}, 800);
}
function displayErrors() {
chrome.storage.local.get("errors", storage => {
storage["errors"].forEach(e => {
document.querySelector("#error_log_output").value += (e + "\n\n");
})
});
}
function displayDarkModeFixUrls() {
let output = document.getElementById("dark-mode-fix-urls");
output.textContent = "";
chrome.storage.sync.get("dark_mode_fix", sync => {
sync["dark_mode_fix"].forEach(url => {
//let div = makeElement("div", "customization-button", output, url);
let div = makeElement("div", output, { "className": "customization-button", "textContent": url });
div.classList.add("fixed-url");
let btn = makeElement("button", div, { "className": "dd", "textContent": "x" });
btn.addEventListener("click", () => {
chrome.storage.sync.get("dark_mode_fix", sync => {
for (let i = 0; i < sync["dark_mode_fix"].length; i++) {
if (sync["dark_mode_fix"][i] === url) {
sync["dark_mode_fix"].splice(i);
chrome.storage.sync.set({ "dark_mode_fix": sync["dark_mode_fix"] }).then(() => div.remove());
}
}
});
})
})
})
}
document.addEventListener("DOMContentLoaded", setup);
// toggle visibility of the Discord/GitHub social links (persists locally)
document.addEventListener("DOMContentLoaded", () => {
const toggle = document.getElementById("social-toggle");
const buttons = document.querySelector(".social-buttons");
if (!toggle || !buttons) return;
const KEY = "hide_socials";
const render = (hidden) => {
buttons.classList.toggle("hidden", hidden);
toggle.textContent = chrome.i18n.getMessage(hidden ? "show_links" : "hide_links");
};
chrome.storage.local.get(KEY, (res) => render(!!res[KEY]));
const flip = () => chrome.storage.local.get(KEY, (res) => {
const next = !res[KEY];
chrome.storage.local.set({ [KEY]: next });
render(next);
});
toggle.addEventListener("click", flip);
toggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); flip(); }
});
});
function setupAssignmentsSlider(initial) {
let el = document.querySelector('#numAssignmentsSlider');
el.value = initial;
document.querySelector('#numAssignments').textContent = initial;
el.addEventListener('input', function () {
document.querySelector('#numAssignments').textContent = this.value;
chrome.storage.sync.set({ "num_assignments": this.value });
});
}
function setupTodoSlider(initial) {
let el = document.querySelector('#numTodoItemsSlider');
el.value = initial;
document.querySelector('#numTodoItems').textContent = initial;
document.querySelector('#numTodoItemsSlider').addEventListener('input', function () {
document.querySelector('#numTodoItems').textContent = this.value;
chrome.storage.sync.set({ "num_todo_items": this.value });
});
}
function setupSidebarScaleSlider(initial) {
let el = document.querySelector("#sidebarScaleSlider");
if (!el) return;
el.value = initial;
document.querySelector("#sidebarScaleValue").textContent = initial;
el.addEventListener("input", function () {
document.querySelector("#sidebarScaleValue").textContent = this.value;
chrome.storage.sync.set({ "sidebar_scale": parseInt(this.value) });
});
}
// Generic range slider (0..max). `key` is the storage id, `valueId` is the
// <span> that shows the live value, `resetId` is the small reset button that
// restores `defaultValue`. `unit` is appended to the displayed value ("%" or "px").
function setupRangeSlider(key, sliderId, valueId, resetId, defaultValue, max, unit, initial) {
const el = document.querySelector("#" + sliderId);
const out = document.querySelector("#" + valueId);
if (!el || !out) return;
const value = Math.max(0, Math.min(max, parseInt(initial)));
if (isNaN(value)) return;
el.value = value;
out.textContent = `${value}${unit}`;
el.addEventListener("input", function () {
out.textContent = `${this.value}${unit}`;
chrome.storage.sync.set({ [key]: parseInt(this.value) });
});
const reset = document.querySelector("#" + resetId);
if (reset) {
reset.addEventListener("click", () => {
const v = Math.max(0, Math.min(max, parseInt(defaultValue)));
el.value = v;
out.textContent = `${v}${unit}`;
chrome.storage.sync.set({ [key]: v });
});
}
}
// Progress display dropdown. Normalizes legacy boolean values:
// true -> rings, false/undefined -> none.
function setupProgressRingsSelect(initial) {
const el = document.querySelector("#todo_progress_rings");
if (!el) return;
let value = initial;
if (value === true) value = "rings";
else if (value === false || value === undefined || value === null) value = "none";
const allowed = ["none", "rings", "rainbow", "lines", "line"];
if (!allowed.includes(value)) value = "rings";
el.value = value;
el.addEventListener("change", function () {
chrome.storage.sync.set({ "todo_progress_rings": this.value });
});
}
// Celebration effects for task completion. `todo_celebrations` holds the
// selected effect ids (multi-select); a random one runs each time a task is
// marked complete. `todo_celebration_amount` scales intensity ("normal" only
// for now, kept as a string so more tiers can be added later).
const CELEBRATION_TYPES = [
{ id: "confetti", label: "Confetti" },
{ id: "fireworks", label: "Fireworks" },
{ id: "sparkles", label: "Sparkle burst" },
{ id: "hearts", label: "Rising hearts" },
{ id: "rocket", label: "Rocket" },
];
// Resolves the stored celebrations value to a list of valid effect ids.
// Handles the legacy single "show confetti on complete" checkbox
// (todo_confetti false meant "off") and drops unknown/legacy ids.
function resolveCelebrations(raw, legacyConfetti) {
if (Array.isArray(raw)) {
return [...new Set(raw.filter(id => CELEBRATION_TYPES.some(t => t.id === id)))];
}
return legacyConfetti === false ? [] : ["confetti"];
}
function setupCelebrationsSelect(initial) {
const wrap = document.getElementById("todo_celebrations");
const btn = document.getElementById("todo_celebrations_btn");
const menu = document.getElementById("todo_celebrations_menu");
const label = document.getElementById("todo_celebrations_label");
if (!wrap || !btn || !menu || !label) return;
let selected = [];
const renderMenu = () => {
menu.innerHTML = CELEBRATION_TYPES.map(t => `
<label class="celebration-option">
<input type="checkbox" value="${t.id}" ${selected.includes(t.id) ? "checked" : ""}>
<span>${t.label}</span>
</label>
`).join("");
menu.querySelectorAll("input").forEach(input => {
input.addEventListener("change", () => {
if (input.checked) {
if (!selected.includes(input.value)) selected.push(input.value);
} else {
selected = selected.filter(id => id !== input.value);
}
updateLabel();
chrome.storage.sync.set({ "todo_celebrations": selected });
});
});
};
const updateLabel = () => {
if (selected.length === 0) label.textContent = "None";
else if (selected.length === 1) label.textContent = CELEBRATION_TYPES.find(t => t.id === selected[0])?.label || "None";
else label.textContent = `${selected.length} selected`;
};
// Read the legacy key so users upgrading from the old "show confetti"
// toggle keep their setting (the special handler only passes the new key).
chrome.storage.sync.get("todo_confetti", legacy => {
selected = resolveCelebrations(initial, legacy && legacy["todo_confetti"]);
renderMenu();
updateLabel();
});
btn.addEventListener("click", (e) => {
e.stopPropagation();
const open = wrap.classList.toggle("open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
// The box sits low in the popup, so the menu often has more room
// above it — flip upward whenever opening downward would overflow.
if (open) {
const mr = menu.getBoundingClientRect();
if (mr.bottom > window.innerHeight - 8) wrap.classList.add("flip-up");
else wrap.classList.remove("flip-up");
}
});
menu.addEventListener("click", (e) => e.stopPropagation());
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
wrap.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
}
});
}
function setupCelebrationAmount(initial) {
const el = document.getElementById("todo_celebration_amount");
if (!el) return;
const allowed = ["less", "normal", "more", "crazy"];
el.value = allowed.includes(initial) ? initial : "normal";
el.addEventListener("change", function () {
chrome.storage.sync.set({ "todo_celebration_amount": this.value });
});
}
// "Preview celebration": asks Canvas tabs to run the current effect + amount
// against a disposable fake task. Only enabled rows respond; if none respond
// (no Canvas tab open) tell the user instead of failing silently.
function setupCelebrationPreview() {
const btn = document.getElementById("celebration-preview-btn");
if (!btn) return;
btn.addEventListener("click", async () => {
const res = await sendFromPopup("previewCelebration");
if (!res || res.ok !== true) {
displayAlert(true, "Open a Canvas page to preview celebrations.");
}
});
}
// The Celebrations master toggle hides the type/amount rows when off.
function toggleCelebrationOptionsVisibility(enabled) {
document.querySelectorAll("#celebrations-container .celebration-sub").forEach(row => {
row.style.display = enabled ? "" : "none";
});
}
// The Celebrations box only makes sense with the Better Todo List, since
// celebrations fire when its tasks are marked complete.
function toggleCelebrationsVisibility(betterTodoOn) {
const el = document.getElementById("celebrations-container");
if (el) el.style.display = betterTodoOn ? "" : "none";
}
// Collapsible "More options" section for the Better Todo List sub-options.
// Persists whether it's expanded so the popup reopens in the same state.
function setupTodoMoreOptions(initial) {
const wrap = document.getElementById("todo-more-options");
const toggle = document.getElementById("todo-more-toggle");
if (!wrap || !toggle) return;
const setExpanded = (expanded) => {
wrap.classList.toggle("expanded", expanded === true);
toggle.setAttribute("aria-expanded", expanded === true ? "true" : "false");
};
setExpanded(initial === true);
const flip = () => {
const expanded = !wrap.classList.contains("expanded");
setExpanded(expanded);
chrome.storage.sync.set({ "todo_more_expanded": expanded });
};
toggle.addEventListener("click", flip);
toggle.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
flip();
}
});
}
// Timeframe dropdown for the upcoming Tasks tab. Persisted so the Better Todo
// List remembers the selected range across sessions. The "custom" option
// reveals two number inputs (days ago / days from now) below the dropdown.
function setTimeframeCustomVisibility(value) {
const wrap = document.getElementById("todo_timeframe_custom");
if (wrap) wrap.classList.toggle("visible", value === "custom");
}
function setupTimeframeSelect(initial) {
const el = document.querySelector("#todo_timeframe");
if (!el) return;
const allowed = ["all", "1week", "2week", "month", "2month", "custom"];
let value = allowed.includes(initial) ? initial : "all";
el.value = value;
setTimeframeCustomVisibility(value);
el.addEventListener("change", function () {
chrome.storage.sync.set({ "todo_timeframe": this.value });
setTimeframeCustomVisibility(this.value);
});
}
// Custom timeframe bounds (shown when Time frame is "custom"): the todo list
// window runs from X days ago to Y days in the future. Clamped to 0-365 so a
// typo can't hide every task.
function setupCustomTimeframeInput(key, initial, fallback) {
const el = document.querySelector("#" + key);
if (!el) return;
const clamp = (v) => Math.max(0, Math.min(365, v));
const parsed = parseInt(initial);
el.value = isNaN(parsed) ? fallback : clamp(parsed);
el.addEventListener("change", function () {
const v = clamp(parseInt(this.value));
this.value = v;
chrome.storage.sync.set({ [key]: v });
});
}
// "Fake grade level" dropdown (Hide personal details). Controls the band the
// generated fake grades fall into, so a screenshot can show all As, all Bs, etc.
function setupAnonGradeSelect(initial) {
const el = document.querySelector("#anon_grade_preset");
if (!el) return;
const allowed = ["high", "medhigh", "med", "medlow"];
el.value = allowed.includes(initial) ? initial : "high";
el.addEventListener("change", function () {
chrome.storage.sync.set({ "anon_grade_level": this.value });
});
}
// "Fake tasks completed" spinner (Hide personal details → fake todo list).
// Sets how many of the 7 fake tasks show as done, which drives the x/y in
// the progress rings' "x/y done" readout.
function setupAnonFakeCompleted(initial) {
const el = document.querySelector("#anon_fake_completed");
if (!el) return;
const clamp = (v) => Math.max(0, Math.min(7, v));
const parsed = parseInt(initial);
el.value = isNaN(parsed) ? 2 : clamp(parsed);
el.addEventListener("change", function () {
const v = clamp(parseInt(this.value));
this.value = v;
chrome.storage.sync.set({ "anon_fake_completed": v });
});
}
// The spinner only means something while "Fill the todo list with fake
// courses" is on, so show/hide it with that checkbox.
function toggleAnonFakeCompletedVisibility(show) {
const wrap = document.getElementById("anon_fake_completed_wrap");
if (wrap) wrap.style.display = show ? "flex" : "none";
}
// "Fake progress display" x/y inputs (Hide personal details). While enabled,
// the todo list's progress readout reports exactly this many completed out
// of this many total, regardless of the real counts.
function setupAnonProgressInput(key, initial, isDone) {
const el = document.querySelector("#" + key);
if (!el) return;
const clamp = (v) => {
if (isDone) {
const total = parseInt(document.querySelector("#anon_progress_total")?.value) || 18;
return Math.max(0, Math.min(999, Math.min(total, v)));
}
return Math.max(1, Math.min(999, v));
};
const parsed = parseInt(initial);
el.value = isNaN(parsed) ? (isDone ? 12 : 18) : clamp(parsed);
el.addEventListener("change", function () {
const v = clamp(parseInt(this.value));
this.value = v;
chrome.storage.sync.set({ [key]: v });
});
}
function setupAutoDarkInput(initial, time) {
let el = document.querySelector('#' + time);
el.value = initial.hour + ":" + initial.minute;
el.addEventListener('change', function () {
let timeinput = { "hour": this.value.split(':')[0], "minute": this.value.split(':')[1] };
time === "auto_dark_start" ? chrome.storage.sync.set({ auto_dark_start: timeinput }) : chrome.storage.sync.set({ auto_dark_end: timeinput });
});
}
function setupCardLimitSlider(initial) {
let el = document.querySelector("#card_limit");
el.value = initial;
document.querySelector("#card_limit_num").textContent = initial;
el.addEventListener("change", (e) => {
chrome.storage.sync.set({ "custom_cards": {}, "custom_cards_2": {}, "custom_cards_3": {}, "card_limit": parseInt(e.target.value)});
});
el.addEventListener("input", (e) => {
document.querySelector("#card_limit_num").textContent = e.target.value;
})
}
function setupDashboardMethod(initial) {
const el = document.getElementById("card_method_dashboard");
el.checked = initial === true ? true : false;
el.addEventListener("change", (e) => {
chrome.storage.sync.set({ "custom_cards": {}, "custom_cards_2": {}, "custom_cards_3": {}, "card_method_dashboard": e.target.checked });
});
}
// Custom card style inputs (image size, roundness, spacing, width, height,
// padding) fire `chrome.storage.sync.set` on every keystroke/arrow press.
// chrome.storage.sync caps writes at MAX_WRITE_OPERATIONS_PER_MINUTE (120/min),
// so rapidly adjusting these number inputs used to hit the quota and surface
// a "quota exceeded" error. Coalesce rapid edits into a single write with a
// short (200ms) debounce — barely noticeable to the user, but stays well
// under the write quota even when dragging arrows or typing fast.
const cardStyleSetTimers = {};
function debouncedCardStyleSet(key, value, delay = 200) {
if (cardStyleSetTimers[key]) clearTimeout(cardStyleSetTimers[key]);
cardStyleSetTimers[key] = setTimeout(() => {
chrome.storage.sync.set({ [key]: value });
}, delay);
}
function setupImageSizeInput(initial) {
let el = document.querySelector("#imageSize");
el.value = initial;
el.addEventListener("input", (e) => {
debouncedCardStyleSet("imageSize", e.target.value);
});
}
function setupCardRoundnessInput(initial) {
let el = document.querySelector("#cardRoundness");
el.value = initial;
el.addEventListener("input", (e) => {
debouncedCardStyleSet("cardRoundness", e.target.value);
});
}
function setupImageRoundnessInput(initial) {
let el = document.querySelector("#imageRoundness");
el.value = initial;
el.addEventListener("input", (e) => {
debouncedCardStyleSet("imageRoundness", e.target.value);
});
}
function setupCardSpacingInput(initial) {
let el = document.querySelector("#cardSpacing");
el.value = initial;
el.addEventListener("input", (e) => {
debouncedCardStyleSet("cardSpacing", e.target.value);
});
}
function setupCardWidthInput(initial) {
let el = document.querySelector("#cardWidth");
el.value = initial;
el.addEventListener("input", (e) => {
debouncedCardStyleSet("cardWidth", e.target.value);
});
}
function setupCardHeightInput(initial) {
let el = document.querySelector("#cardHeight");
el.value = initial;
el.addEventListener("input", (e) => {
debouncedCardStyleSet("cardHeight", e.target.value);
});
}
function setupCardPaddingInput(initial) {
let el = document.querySelector("#cardPadding");
el.value = initial;
el.addEventListener("input", (e) => {
debouncedCardStyleSet("cardPadding", e.target.value);
});
}
// Card Grid (dashboard card layout). The toggle lives in menu.checkboxes;
// this wires the reveal of the options panel plus the squares preview and
// the "some cards may be cut off" warning.
function setupCardGrid(initial) {
const panel = document.getElementById("card-grid-options");
if (!panel) return;
panel.style.display = initial === true ? "" : "none";
// Defer the first render: setupCardGrid runs inside the special-options
// loop BEFORE the column/row/gap inputs get their stored values
// (setupCardGridCountInput), so an immediate render would use the HTML
// defaults and look wrong until a value changes. A 0ms timeout runs once
// the whole synchronous setup pass is done.
setTimeout(() => updateCardGridPreview(), 0);
document.getElementById("card_grid").addEventListener("change", (e) => {
panel.style.display = e.target.checked ? "" : "none";
if (e.target.checked) updateCardGridPreview();
});
// Row-centering only affects the preview's last-row offset.
document.getElementById("card_grid_center_rows").addEventListener("change", () => {
updateCardGridPreview();
});
// Flexible grid changes what the warning means (cards wrap instead of
// being cut off), so re-render it on toggle.
document.getElementById("card_grid_flex")?.addEventListener("change", () => {
updateCardGridPreview();
});
}
// Grid count/spacing inputs for the card grid. Update the preview
// immediately on every keystroke, but debounce the storage write like the
// other card style number inputs. `fallback` is used when the field is
// cleared/invalid — 1 for counts, 0 for spacings.
function setupCardGridCountInput(id, initial, fallback = 1) {
const el = document.getElementById(id);
if (!el) return;
el.value = initial;
el.addEventListener("input", (e) => {
updateCardGridPreview();
const num = parseInt(e.target.value, 10);
debouncedCardStyleSet(id, isNaN(num) ? fallback : num);
});
}
// Fallback count of dashboard cards that will occupy grid cells, used only
// when no dashboard tab answered the live "getGridCardCount" query (e.g. the
// options page was opened outside a Canvas window). Every stored card that
// isn't hidden via the card menu would be shown, so it doesn't take up a
// grid slot. Treated as truthy so legacy string values ("true") count too.
function countActiveDashboardCards(cards) {
return Object.values(cards || {}).filter(c => c && typeof c === "object" && !c.hidden && !c.hide).length;
}
// Squares preview: one square per grid cell. Filled squares = cells covered
// by an active (non-hidden) card; dim squares = empty cells. Warns when the
// grid can't fit every active card.
function updateCardGridPreview() {
const panel = document.getElementById("card-grid-options");
const preview = document.getElementById("card-grid-preview");
if (!panel || !preview || panel.style.display === "none") return;
const cols = Math.max(1, parseInt(document.getElementById("card_grid_columns").value, 10) || 1);
const rows = Math.max(1, parseInt(document.getElementById("card_grid_rows").value, 10) || 1);
const colGapRaw = parseInt(document.getElementById("card_grid_column_gap").value, 10);
const colGap = Math.max(0, Math.min(10, isNaN(colGapRaw) ? 12 : colGapRaw));
const total = cols * rows;
preview.style.gridTemplateColumns = `repeat(${cols}, 18px)`;
// Echo the chosen column spacing in the preview (clamped so extreme
// values don't blow the layout out).
preview.style.columnGap = `${colGap}px`;
preview.style.rowGap = "4px";
preview.innerHTML = "";
chrome.storage.sync.get(["custom_cards", "gpa_calc"], async s => {
// Grid may have been toggled off while the storage read was pending.
if (document.getElementById("card-grid-options").style.display === "none") return;
// Prefer the live count from an open dashboard tab: the page itself
// knows exactly which cards are rendered and not display:none (hidden
// via the card menu), so the preview/warning can't drift out of sync
// with stored data (stale entries, other-domain cards, legacy values).
// Fall back to the storage-derived count when no dashboard answers.
const live = await sendFromPopup("getGridCardCount");
let active = (live && typeof live.count === "number" && live.count >= 0)
? live.count
: countActiveDashboardCards(s.custom_cards);
// The GPA calculator card occupies one card slot in the grid too.
if (s.gpa_calc === true) active += 1;
// Uneven-row centering: the real grid shifts the last partial row in
// half-card steps (double sub-columns — see centerUnevenGridRows).
// In the preview that's a margin on every square of that row; since
// the squares shift into space where no neighbor sits, nothing
// overlaps.
let lastRowStart = -1, offset = 0;
if (document.getElementById("card_grid_center_rows")?.checked === true && active > 0 && active < total) {
lastRowStart = Math.floor((active - 1) / cols) * cols;
const inLastRow = active - lastRowStart;
if (inLastRow < cols) offset = cols - inLastRow;
}
for (let i = 0; i < total; i++) {
const inShiftedRow = lastRowStart !== -1 && i >= lastRowStart;
// The shifted row's empty cells aren't rendered — the row's filled
// squares carry the offset, so the empties would misrepresent it.
if (inShiftedRow && i >= active) continue;
const square = document.createElement("div");
square.style.cssText = "width:18px;height:18px;border-radius:4px;box-sizing:border-box;";
square.style.background = i < active ? "#56Caf0" : "#3c3c3c";
if (inShiftedRow && offset > 0) {
square.style.marginLeft = `${offset * (18 + colGap) / 2}px`;
}
preview.appendChild(square);
}
const warning = document.getElementById("card-grid-warning");
if (warning) {
if (s.card_grid_flex === true) {
// Flexible grid never clips: cards wrap into new rows as the
// window narrows, so the cut-off warning doesn't apply.
warning.textContent = "Flexible grid is on - cards wrap to fit narrow windows instead of being cut off.";
warning.style.display = "block";
} else if (active > total) {
const gpaNote = s.gpa_calc === true ? " (including the GPA calculator)" : "";
warning.textContent = `\u26A0 Your grid holds ${total} card${total === 1 ? "" : "s"} but you have ${active} card${active === 1 ? "" : "s"}${gpaNote} - some cards may be cut off.`;
warning.style.display = "block";
} else {
warning.style.display = "none";
}
}
});
}
function setupCustomBackgroundLink(initial) {
let el = document.querySelector("#customBackgroundLink");
el.value = initial || "";
toggleOpacityOptions();
el.addEventListener("input", (e) => {
chrome.storage.sync.set({ "customBackgroundLink": e.target.value });
renderBackgroundPresetSelection();
toggleOpacityOptions();
});
}
function setupCustomBackgroundScale(initial) {
const el = document.querySelector("#customBackgroundScale");
const output = document.querySelector("#customBackgroundScaleValue");
if (!el || !output) return;
const value = Number(initial) || 100;
el.value = value;
output.textContent = `${value}%`;
el.addEventListener("input", (e) => {
const nextValue = parseInt(e.target.value);
output.textContent = `${nextValue}%`;
chrome.storage.sync.set({ "customBackgroundScale": nextValue });
renderBackgroundPresetSelection();
});
}
function getDailyBackgroundPreset() {
if (typeof backgroundPresets === "undefined" || !Array.isArray(backgroundPresets) || backgroundPresets.length === 0) {
return null;
}
const today = new Date();
const dayNumber = Math.floor(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) / 86400000);
return backgroundPresets[Math.abs(dayNumber) % backgroundPresets.length];
}
function syncCustomBackgroundDailyState(isDaily) {
const manualControls = document.getElementById("customBackgroundManualControls");
if (manualControls) {
manualControls.style.opacity = isDaily ? "0.45" : "1";
manualControls.style.pointerEvents = isDaily ? "none" : "auto";
manualControls.style.filter = isDaily ? "grayscale(1)" : "none";
}
["customBackgroundLink", "customBackgroundScale", "clearCustomBackground"].forEach(id => {
const el = document.getElementById(id);
if (el) el.disabled = isDaily;
});
document.querySelectorAll(".background-preset-card").forEach(button => {
button.disabled = isDaily;
});
renderBackgroundPresetSelection();
}
function renderBackgroundPresetSelection() {
const isDaily = document.querySelector("#customBackgroundDaily")?.checked === true || document.querySelector("#customBackgroundNasaDaily")?.checked === true;
const dailyPreset = isDaily ? getDailyBackgroundPreset() : null;
const currentLink = isDaily ? (dailyPreset?.url || "") : (document.querySelector("#customBackgroundLink")?.value || "");
const currentScale = isDaily ? String(dailyPreset?.scale || "100") : String(document.querySelector("#customBackgroundScale")?.value || "100");
document.querySelectorAll(".background-preset-card").forEach(button => {
const matchesLink = button.dataset.backgroundUrl === currentLink;
const matchesScale = button.dataset.backgroundScale === currentScale;
button.classList.toggle("selected", matchesLink && matchesScale);
});
}
function displayBackgroundPresets() {
const container = document.querySelector("#background-presets");
if (!container || container.dataset.rendered === "true" || typeof backgroundPresets === "undefined") return;
container.dataset.rendered = "true";
container.innerHTML = backgroundPresets.map(preset => `
<button type="button" class="theme-button customization-button background-preset-card" data-background-url="${preset.url}" data-background-scale="${preset.scale}" style="background-image: linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42)), url('${preset.url}');">
<p class="theme-button-title">${preset.title}</p>
<p class="theme-button-creator">${preset.credit}</p>
<p class="background-preset-scale">Scale ${preset.scale}%</p>
</button>
`).join("");
container.querySelectorAll(".background-preset-card").forEach(button => {
button.addEventListener("click", () => {
const backgroundUrl = button.dataset.backgroundUrl;
const backgroundScale = parseInt(button.dataset.backgroundScale);
document.querySelector("#customBackgroundLink").value = backgroundUrl;
document.querySelector("#customBackgroundScale").value = backgroundScale;
document.querySelector("#customBackgroundScaleValue").textContent = `${backgroundScale}%`;
chrome.storage.sync.set({ "customBackgroundLink": backgroundUrl, "customBackgroundScale": backgroundScale });
renderBackgroundPresetSelection();
toggleOpacityOptions();
});
});
renderBackgroundPresetSelection();
syncCustomBackgroundDailyState(document.querySelector("#customBackgroundDaily")?.checked === true || document.querySelector("#customBackgroundNasaDaily")?.checked === true);
}
function toggleBetterSidebarSubOptions(betterSidebarOn) {
const remlogoEl = document.getElementById("remlogo");
if (remlogoEl) {
remlogoEl.style.display = betterSidebarOn ? "none" : "flex";
}
const sidebarScaleEl = document.getElementById("sidebarScaleSub");
if (sidebarScaleEl) {
sidebarScaleEl.style.display = betterSidebarOn ? "block" : "none";
}
}
// The opacity sliders live under "Edit dark mode" but only make sense (and
// only show) when a custom background is active, since transparency without a
// background image behind these surfaces would just expose the dark body.
function customBackgroundActive() {
const link = document.querySelector("#customBackgroundLink");
const daily = document.querySelector("#customBackgroundDaily");
const nasa = document.querySelector("#customBackgroundNasaDaily");
return (link && link.value.trim() !== "") || (daily && daily.checked) || (nasa && nasa.checked);
}
function toggleOpacityOptions() {
const el = document.getElementById("opacity-options");
if (el) el.style.display = customBackgroundActive() ? "" : "none";
toggleCardTransparencyOptions();
}
// Card opacity/blur sliders only show when both a custom background is active
// (so #opacity-options is visible) and the "Card transparency" checkbox is on.
function toggleCardTransparencyOptions() {
const on = document.getElementById("card_transparency")?.checked === true;
const opacityRow = document.getElementById("card-transparency-options");
const blurRow = document.getElementById("card-transparency-blur-row");
if (opacityRow) opacityRow.style.display = on ? "" : "none";
if (blurRow) blurRow.style.display = on ? "" : "none";
}
// "Alternate colors" (Better Todo List sub-option) only makes sense in light
// mode, so hide its checkbox whenever dark mode is on.
function toggleAlternateColorsVisibility(darkModeOn) {
const wrap = document.getElementById("todo_alternate_colors_wrap");
if (wrap) wrap.style.display = darkModeOn ? "none" : "";
}
// Widgets tab: position dropdowns for widgets that support placement.
const WIDGET_LIST = [
{ id: "dashboard_notes", nameKey: "dashboard_notes", shortKey: "widget_short_notes" },
{ id: "gpa_calc", nameKey: "gpa_calculator", shortKey: "widget_short_gpa" },
{ id: "focus_timer", nameKey: "focus_timer", shortKey: "widget_short_focus" },
{ id: "breathing", nameKey: "breathing_breaks", shortKey: "widget_short_breathing" },
{ id: "daily_quotes", nameKey: "daily_quotes", shortKey: "widget_short_quotes" },
];
function widgetPosFromSync(id, sync) {
if (id === "dashboard_notes") return sync.dashboard_notes_position || "top";
if (id === "gpa_calc") {
if (sync.gpa_calc_position !== undefined) return sync.gpa_calc_position;
return sync.gpa_calc_prepend === true ? "cards_start" : "cards_end";
}
return sync[id + "_position"] || (defaultOptions.sync[id + "_position"]) || "top";
}
const WIDGET_ORDER_MIN_PCT = 15;
const WIDGET_ORDER_SNAP_PCT = 4;
// widget_order entries: [{id, size}] where size is the widget's % width of
// the shared container. Legacy plain-id arrays are treated as equal sizes.
function parseWidgetOrderEntries(raw) {
if (!Array.isArray(raw)) return [];
return raw.map(e => {
if (typeof e === "string") return { id: e, size: null };
if (e && typeof e === "object" && e.id) {
const n = Number(e.size);
return { id: e.id, size: Number.isFinite(n) && n > 0 ? n : null };
}
return null;
}).filter(e => e && WIDGET_LIST.some(w => w.id === e.id));
}
function widgetDisplayName(id) {
const info = WIDGET_LIST.find(w => w.id === id);
const msg = info && chrome.i18n.getMessage(info.shortKey || info.nameKey);
if (msg) return msg;
// Locales that predate a widget have no message: show a friendly label
// instead of the raw storage id.
return id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
}
// Live-refresh hook for the widget-order UI, set by setupWidgetOrder and
// called directly after the popup itself changes a widget's position or
// enabled state — so the order box updates instantly without waiting for
// the storage.onChanged round-trip.
let refreshWidgetOrder = null;
function setupWidgetOrder() {
const wrap = document.getElementById("widget-order-container");
if (!wrap) return;
const KEYS = WIDGET_LIST.map(w => w.id)
.concat(WIDGET_LIST.map(w => w.id + "_position"))
.concat(["gpa_calc_prepend", "widget_order"]);
const AREAS = ["top", "cards_start", "cards_end", "below"];
const render = (sync) => {
const areas = { top: [], cards_start: [], cards_end: [], below: [] };
WIDGET_LIST.forEach(w => {
if (sync[w.id] !== true) return;
const pos = widgetPosFromSync(w.id, sync);
if (areas[pos]) areas[pos].push(w.id);
});
let any = false;
AREAS.forEach(area => {
const strip = document.getElementById("widget-order-" + area);
const wrapEl = document.getElementById("widget-order-" + area + "-wrap");
if (!strip || !wrapEl) return;
strip.dataset.area = area;
const ids = areas[area];
// A section only makes sense once 2+ widgets share the same
// position — with a single widget there is nothing to order.
if (ids.length < 2) {
wrapEl.style.display = "none";
return;
}
any = true;
wrapEl.style.display = "";
// Saved entries first (order + widths), then any newcomers. The
// before/after groups share the single "cards" saved order; each
// strip only shows the widgets actually in its group.
const saved = (sync.widget_order || {})[area.startsWith("cards") ? "cards" : area];
const entries = parseWidgetOrderEntries(saved).filter(e => ids.includes(e.id));
ids.forEach(id => { if (!entries.some(e => e.id === id)) entries.push({ id, size: null }); });
renderWidgetOrderStrip(strip, entries);
});
wrap.style.display = any ? "" : "none";
};
// Reordering: drag a box horizontally past a neighbor.
AREAS.forEach(area => {
const strip = document.getElementById("widget-order-" + area);
if (!strip) return;
strip.addEventListener("dragover", e => {
e.preventDefault();
const dragging = strip.querySelector(".wos-item.dragging");
if (!dragging) return;
let ref = null;
for (const el of strip.querySelectorAll(".wos-item:not(.dragging)")) {
const box = el.getBoundingClientRect();
if (e.clientX < box.left + box.width / 2) { ref = el; break; }
}
if (ref) strip.insertBefore(dragging, ref);
else strip.appendChild(dragging);
});
strip.addEventListener("drop", e => e.preventDefault());
});
const refresh = () => chrome.storage.sync.get(KEYS, render);
refreshWidgetOrder = refresh;
refresh();
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync") return;
if (KEYS.some(k => changes[k])) refresh();
});
}
function renderWidgetOrderStrip(strip, entries) {
strip.textContent = "";
// Before/after strips are sequence only — no width resizing, no dividers.
const resizable = !strip.dataset.area.startsWith("cards");
// Normalize widths to exactly 100%: a widget with no saved size borrows
// the average of the sized ones. Without this, adding a third widget to a
// previously 50/50 pair produced 50+50+33 = 133% and the strip overflowed.
let sizes = entries.map(e => (Number.isFinite(e.size) && e.size > 0) ? e.size : null);
const known = sizes.filter(v => v !== null);
const fallback = known.length ? known.reduce((a, b) => a + b, 0) / known.length : 100 / Math.max(1, entries.length);
sizes = sizes.map(v => v === null ? fallback : v);
const total = sizes.reduce((a, b) => a + b, 0) || 1;
const pcts = sizes.map(v => v / total * 100);
entries.forEach((entry, i) => {
if (resizable && i > 0) {
const divider = document.createElement("div");
divider.className = "wos-divider";
wireWidgetOrderDivider(strip, divider);
strip.appendChild(divider);
}
const item = document.createElement("div");
item.className = "wos-item";
item.dataset.id = entry.id;
if (resizable) {
item.dataset.size = pcts[i];
item.style.flexBasis = pcts[i] + "%";
}
item.draggable = true;
item.textContent = widgetDisplayName(entry.id);
item.addEventListener("dragstart", e => {
item.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", entry.id); } catch (_) {}
});
item.addEventListener("dragend", () => {
item.classList.remove("dragging");
saveWidgetStrip(strip.dataset.area, strip);
});
strip.appendChild(item);
});
}
// Dragging a divider resizes its two neighboring boxes; the divider position
// is exactly the % split between them.
function wireWidgetOrderDivider(strip, divider) {
divider.addEventListener("pointerdown", e => {
e.preventDefault();
try { divider.setPointerCapture(e.pointerId); } catch (_) {}
const idx = [...strip.children].indexOf(divider);
const prev = strip.children[idx - 1];
const next = strip.children[idx + 1];
if (!prev || !next || !prev.classList.contains("wos-item") || !next.classList.contains("wos-item")) return;
const stripWidth = strip.getBoundingClientRect().width || 1;
const startX = e.clientX;
const p0 = parseFloat(prev.dataset.size);
const n0 = parseFloat(next.dataset.size);
const onMove = ev => {
const dx = (ev.clientX - startX) / stripWidth * 100;
let p = p0 + dx;
p = Math.max(WIDGET_ORDER_MIN_PCT, Math.min(p, p0 + n0 - WIDGET_ORDER_MIN_PCT));
// Snap to even fractions of the widget count: with 2 widgets the
// bar snaps to 50/50, with 3 to the 1/3 and 2/3 marks, etc.
const count = strip.querySelectorAll(".wos-item").length;
for (let k = 1; k < count; k++) {
const mark = k / count * 100;
if (Math.abs(p - mark) < WIDGET_ORDER_SNAP_PCT) { p = mark; break; }
}
const n = p0 + n0 - p;
prev.dataset.size = p;
next.dataset.size = n;
prev.style.flexBasis = p + "%";
next.style.flexBasis = n + "%";
};
const onUp = () => {
divider.removeEventListener("pointermove", onMove);
divider.removeEventListener("pointerup", onUp);
divider.removeEventListener("pointercancel", onUp);
saveWidgetStrip(strip.dataset.area, strip);
};
divider.addEventListener("pointermove", onMove);
divider.addEventListener("pointerup", onUp);
divider.addEventListener("pointercancel", onUp);
});
}
function saveWidgetStrip(area, strip) {
const items = [...strip.querySelectorAll(".wos-item")];
if (items.length < 2) return;
const order = items.map(el => ({ id: el.dataset.id }));
// Before/after strips both save into the shared "cards" order, merged
// with the entries of the other group so nothing is lost.
const storageArea = area.startsWith("cards") ? "cards" : area;
chrome.storage.sync.get(["widget_order"], sync => {
const wo = Object.assign({}, sync.widget_order || {});
if (storageArea === "cards") {
const ids = new Set(order.map(e => e.id));
const other = (sync.widget_order && Array.isArray(sync.widget_order.cards) ? sync.widget_order.cards : [])
.filter(e => e && !ids.has(e.id));
wo.cards = other.concat(order);
} else {
// Container rows still carry each widget's % width.
let sizes = items.map(el => parseFloat(el.dataset.size) || 100 / items.length);
const total = sizes.reduce((a, b) => a + b, 0) || 100;
sizes = sizes.map(v => v / total * 100);
const rounded = sizes.map(v => Math.round(v));
rounded[rounded.length - 1] += 100 - rounded.reduce((a, b) => a + b, 0);
wo[storageArea] = items.map((el, i) => ({ id: el.dataset.id, size: rounded[i] }));
}
chrome.storage.sync.set({ widget_order: wo });
});
}
function setupWidgetPosition() {
[
{ select: "dashboard_notes_position", key: "dashboard_notes_position" },
{ select: "gpa_calc_position", key: "gpa_calc_position" },
{ select: "focus_timer_position", key: "focus_timer_position" },
{ select: "breathing_position", key: "breathing_position" },
{ select: "daily_quotes_position", key: "daily_quotes_position" },
].forEach(({ select, key }) => {
const el = document.getElementById(select);
if (!el) return;
chrome.storage.sync.get([key, "gpa_calc_prepend"], sync => {
let value = sync[key];
if (value === undefined && key === "gpa_calc_position") {
// Legacy "Move to top" checkbox -> equivalent dropdown position.
value = sync["gpa_calc_prepend"] === true ? "cards_start" : "cards_end";
chrome.storage.sync.set({ [key]: value });
}
el.value = value || defaultOptions.sync[key] || "top";
});
el.addEventListener("change", function () {
chrome.storage.sync.set({ [key]: this.value });
});
});
}
// Widget height settings (px). 0 / blank means auto.
function setupWidgetHeightInput(id, initial) {
const el = document.getElementById(id);
if (!el) return;
const parse = (v) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 0;
};
el.value = parse(initial) || "";
el.addEventListener("change", function () {
const n = Math.min(2000, Math.max(0, parseInt(this.value, 10) || 0));
this.value = n > 0 ? n : "";
chrome.storage.sync.set({ [id]: n });
});
}
// Hours/minutes/seconds widgets settings (Focus Timer / Breathing presets).
// The hours field is optional — Breathing only exposes minutes/seconds.
// Fields are compact h:mm:ss inputs: click to edit a segment, typing a second
// digit auto-advances, and the value is saved (zero-padded) on blur/change.
function setupWidgetTimeInput(prefix, initial) {
const hEl = document.getElementById(prefix + "_h");
const mEl = document.getElementById(prefix + "_m");
const sEl = document.getElementById(prefix + "_s");
if (!mEl || !sEl) return;
let v = initial;
if (!v || typeof v !== "object") {
// Legacy plain-minutes value.
const n = parseInt(v, 10);
v = Number.isFinite(n) && n > 0 ? { h: 0, m: n, s: 0 } : (defaultOptions.sync[prefix] || { h: 0, m: 0, s: 0 });
}
const fields = [hEl, mEl, sEl].filter(Boolean);
const pad = (n) => String(Math.max(0, parseInt(n, 10) || 0)).padStart(2, "0");
if (hEl) hEl.value = pad(v.h);
mEl.value = pad(v.m);
sEl.value = pad(v.s);
const save = () => {
const clamp = (el, max) => Math.min(max, Math.max(0, parseInt(el.value, 10) || 0));
let h = hEl ? clamp(hEl, 99) : 0;
let m = clamp(mEl, 59);
let s = clamp(sEl, 59);
if (h === 0 && m === 0 && s === 0) {
const d = defaultOptions.sync[prefix] || { h: 0, m: 5, s: 0 };
h = hEl ? d.h : 0; m = d.m; s = d.s;
}
if (hEl) hEl.value = pad(h);
mEl.value = pad(m);
sEl.value = pad(s);
chrome.storage.sync.set({ [prefix]: { h, m, s } });
};
fields.forEach((inp, i) => {
inp.addEventListener("focus", () => inp.select());
inp.addEventListener("input", () => {
inp.value = inp.value.replace(/[^0-9]/g, "").slice(0, 2);
if (inp.value.length >= 2 && i < fields.length - 1) fields[i + 1].focus();
});
inp.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); save(); inp.blur(); }
else if (e.key === "ArrowRight" && i < fields.length - 1) { e.preventDefault(); fields[i + 1].focus(); }
else if (e.key === "ArrowLeft" && i > 0) { e.preventDefault(); fields[i - 1].focus(); }
});
inp.addEventListener("change", save);
inp.addEventListener("blur", save);
});
}
// Hide a toggle's sub-options when it's off; auto_dark only hides its time
// clocks.
function toggleSubOptionsVisibility(option, isOn) {
const togglesWithSubOptions = ["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"];
if (!togglesWithSubOptions.includes(option)) return;
const optionEl = document.getElementById(option);
if (!optionEl) return;
const subOptions = optionEl.parentElement.querySelector(":scope > .sub-options");
if (!subOptions) return;
if (option === "auto_dark") {
const timesets = subOptions.querySelector(".timesets");
if (timesets) timesets.style.display = isOn ? "" : "none";
} else {
subOptions.style.display = isOn ? "" : "none";
}
}
function setupFeatureSearch(menu) {
const searchInput = document.querySelector("#feature-search");
const resultsEl = document.querySelector("#feature-search-results");
const headerSearch = document.querySelector("#header-search");
if (!searchInput || !resultsEl || !headerSearch) return;
// Map (reference-keyed) each .tab element -> the button id that opens it.
// A plain object won't work: DOM elements stringify to the same key.
const tabElToBtnId = new Map();
Object.entries(menu.tabs).forEach(([btnId, info]) => {
const tabEl = document.querySelector(info.tab);
if (tabEl) tabElToBtnId.set(tabEl, btnId);
});
function shouldSkip(el) {
// Skip overlays / scrapped containers that aren't real features
if (el.closest('#submit-popup, #browser-settings-popup, #opt-in, #card-edit-menu')) return true;
// Skip statically-hidden containers but keep dynamically-hidden sub-options.
let node = el;
while (node && node !== document.body) {
if (node.classList && node.classList.contains('option-container') && node.style.display === 'none') return true;
node = node.parentElement;
}
return false;
}
function labelOf(sub) {
const label = sub.querySelector("label");
if (label) return label.textContent.trim();
const st = sub.querySelector(".sub-text");
if (st) return st.textContent.trim();
// Bare <span> label (e.g. Export Settings checkboxes have no label/.sub-text).
const span = sub.querySelector("span");
if (span) return span.textContent.trim();
return "";
}
function keyIdOf(sub) {
const label = sub.querySelector("label");
if (label && label.getAttribute("for")) return label.getAttribute("for");
const input = sub.querySelector("input");
if (input && input.id) return input.id;
return labelOf(sub);
}
function categoryFor(el) {
const tabEl = el.closest(".tab");
if (tabEl) {
const btnId = tabElToBtnId.get(tabEl);
if (btnId) {
const btn = document.getElementById(btnId);
const span = btn && btn.querySelector("span");
if (span) return span.textContent.trim();
}
return "Section";
}
if (el.classList && el.classList.contains("tab-btn")) return "Section";
if (el.classList && el.classList.contains("option")) return "Settings";
const owner = el.closest(".option");
if (owner) {
const name = owner.querySelector(".option-name");
if (name) return name.textContent.trim();
}
return "Settings";
}
function openTab(btnId) {
const btn = document.getElementById(btnId);
if (btn) btn.click();
}
function showMain() {
const back = document.querySelector(".back-btn");
if (back) back.click();
}
// Reveal any hidden sub-option blocks between el and .main so the target
// is visible (purely visual; doesn't change stored settings).
function revealAncestors(el) {
const main = document.querySelector(".main");
let node = el;
while (node && node !== main && node !== document.body) {
if (node.style && node.style.display === "none") node.style.display = "";
node = node.parentElement;
}
// Expand the Better Todo "More options" collapsible if the target lives inside it.
const moreWrap = el.closest ? el.closest("#todo-more-options") : null;
if (moreWrap && !moreWrap.classList.contains("expanded")) {
const toggle = document.getElementById("todo-more-toggle");
if (toggle) toggle.click();
}
}
function highlight(el) {
el.classList.add("search-highlight");
setTimeout(() => el.classList.remove("search-highlight"), 2000);
}
function goToMainElement(el) {
showMain();
revealAncestors(el);
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
highlight(el);
});
}
function goToTabElement(el) {
const tabEl = el.closest(".tab");
const btnId = tabElToBtnId.get(tabEl);
if (btnId) openTab(btnId);
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
highlight(el);
});
}
let featureIndex = null;
let currentMatches = [];
let activeIndex = -1;
function buildIndex() {
const index = [];
const seen = new Set();
function add(entry) {
if (!entry.text || seen.has(entry.key)) return;
seen.add(entry.key);
index.push(entry);
}
// Big section buttons (Edit Dark Mode, Cards, Themes, Styles, GPA Settings, Report issue)
document.querySelectorAll(".more-options-container .tab-btn").forEach(btn => {
const span = btn.querySelector("span");
add({ key: "tab:" + btn.id, text: span ? span.textContent.trim() : "", el: btn, action: () => openTab(btn.id) });
});
// Home: option toggles
document.querySelectorAll(".options .option").forEach(opt => {
if (!opt.id) return;
const name = opt.querySelector(".option-name");
if (!name) return;
if (shouldSkip(opt)) return;
add({ key: "option:" + opt.id, text: name.textContent.trim(), el: opt, action: () => goToMainElement(opt) });
});
// Tabs: option toggles that live inside a tab (e.g. Dashboard Notes in Widgets)
document.querySelectorAll(".tab .option").forEach(opt => {
if (!opt.id) return;
const name = opt.querySelector(".option-name");
if (!name) return;
if (shouldSkip(opt)) return;
add({ key: "option:" + opt.id, text: name.textContent.trim(), el: opt, action: () => goToTabElement(opt) });
});
// Home: sub-options / timesets / labelled rows (e.g. "Use dd/mm", "Start time", max-items)
document.querySelectorAll(".options .sub-option, .options .timeset, .options .sub-options > div").forEach(sub => {
if (shouldSkip(sub)) return;
// Skip statically-hidden sub-options; dynamically-hidden ones stay indexed.
if (sub.classList.contains("sub-option") && sub.style.display === "none") return;
const text = labelOf(sub);
if (!text) return;
add({ key: "sub:" + keyIdOf(sub), text, el: sub, action: () => goToMainElement(sub) });
});
// Home: custom Canvas URLs
const customDomain = document.querySelector(".customDomain");
if (customDomain && !shouldSkip(customDomain)) {
const wrap = customDomain.closest(".customDomain");
const label = wrap ? wrap.querySelector("[data-i18n='enter_url']") : null;
add({ key: "custom:customDomain", text: label ? label.textContent.trim() : "Canvas URL", el: wrap || customDomain, action: () => goToMainElement(wrap || customDomain) });
}
// Tabs: section headings (e.g. "Presets", "Custom styles", "Popular Palettes", "Custom Background")
document.querySelectorAll(".tab .header-small").forEach(h => {
if (shouldSkip(h)) return;
const text = h.textContent.trim();
if (!text) return;
const btnId = tabElToBtnId.get(h.closest(".tab"));
add({ key: "heading:" + (btnId || "?") + ":" + text, text, el: h, action: () => goToTabElement(h) });
});
// Tabs: checkbox sub-options (e.g. "Daily Random Image", "Custom Card Styles")
document.querySelectorAll(".tab .sub-option").forEach(sub => {
if (shouldSkip(sub)) return;
const text = labelOf(sub);
if (!text) return;
const btnId = tabElToBtnId.get(sub.closest(".tab"));
add({ key: "tabsub:" + (btnId || "?") + ":" + keyIdOf(sub), text, el: sub, action: () => goToTabElement(sub) });
});
// Dark mode color fields (e.g. "Sidebar Text", "Background Main")
document.querySelectorAll(".tab .color-type-header").forEach(h => {
if (shouldSkip(h)) return;
const text = h.textContent.trim();
if (!text) return;
const btnId = tabElToBtnId.get(h.closest(".tab"));
add({ key: "color:" + (btnId || "?") + ":" + text, text, el: h, action: () => goToTabElement(h) });
});
// Tab: range sliders that live in dedicated rows (Opacity & Blur, Image scale).
// These aren't .sub-option, so the generic blocks above miss them.
document.querySelectorAll(".tab .opacity-slider-row, .tab .background-scale-row").forEach(row => {
if (shouldSkip(row)) return;
const label = row.querySelector(".sub-text");
const input = row.querySelector("input[type='range']");
if (!label || !input) return;
const text = label.textContent.trim();
if (!text) return;
const btnId = tabElToBtnId.get(row.closest(".tab"));
add({ key: "slider:" + (btnId || "?") + ":" + (input.id || text), text, el: row, action: () => goToTabElement(row) });
});
// Tab: preset buttons (dark mode presets + popular color palettes).
document.querySelectorAll(".tab .preset-button").forEach(btn => {
if (shouldSkip(btn)) return;
const text = btn.textContent.trim().replace(/\s+/g, " ");
if (!text || text === "placeholder") return;
const btnId = tabElToBtnId.get(btn.closest(".tab"));
add({ key: "preset:" + (btnId || "?") + ":" + (btn.id || text), text, el: btn, action: () => goToTabElement(btn) });
});
// Tab: standalone action buttons with distinct labels (GPA scale presets, revert colors).
["revert-colors", "clearCustomBackground"].forEach(id => {
const btn = document.getElementById(id);
if (!btn || shouldSkip(btn)) return;
const text = btn.textContent.trim();
if (!text) return;
add({ key: "action:" + id, text, el: btn, action: () => goToTabElement(btn) });
});
// Tab: checkbox/radio controls whose label is a bare sibling span and that
// aren't wrapped in .sub-option (e.g. "Get Active Cards From Dashboard",
// sidebar background "Solid/Gradient/Image").
document.querySelectorAll(".tab input[type='checkbox'], .tab input[type='radio']").forEach(input => {
if (!input.id) return;
if (input.closest(".sub-option") || input.closest(".option")) return;
if (shouldSkip(input)) return;
const text = (input.parentElement && input.parentElement.textContent.trim()) || "";
if (!text) return;
const btnId = tabElToBtnId.get(input.closest(".tab"));
add({ key: "control:" + (btnId || "?") + ":" + input.id, text, el: input, action: () => goToTabElement(input) });
});
return index;
}
function filterFeatures(query) {
const q = query.trim().toLowerCase();
if (!q) return [];
if (!featureIndex) featureIndex = buildIndex();
const scored = [];
for (const entry of featureIndex) {
const t = entry.text.toLowerCase();
let score = -1;
if (t === q) score = 1000;
else if (t.startsWith(q)) score = 500 - t.length;
else {
const idx = t.indexOf(q);
if (idx !== -1) score = 200 - idx;
else if (t.split(/\s+/).some(w => w.toLowerCase().startsWith(q))) score = 50;
}
if (score >= 0) scored.push({ entry, score });
}
scored.sort((a, b) => b.score - a.score || a.entry.text.length - b.entry.text.length);
return scored.slice(0, 12).map(s => s.entry);
}
function renderResults(matches) {
currentMatches = matches;
activeIndex = matches.length ? 0 : -1;
resultsEl.innerHTML = "";
if (!matches.length) {
const empty = document.createElement("div");
empty.className = "search-result empty";
empty.textContent = "No features found";
resultsEl.appendChild(empty);
resultsEl.classList.add("open");
return;
}
matches.forEach((entry, i) => {
const item = document.createElement("div");
item.className = "search-result" + (i === activeIndex ? " active" : "");
item.setAttribute("role", "option");
const title = document.createElement("span");
title.className = "search-result-title";
title.textContent = entry.text;
const cat = document.createElement("span");
cat.className = "search-result-category";
cat.textContent = categoryFor(entry.el);
item.appendChild(title);
item.appendChild(cat);
item.addEventListener("mousedown", (e) => {
e.preventDefault();
selectResult(i);
});
resultsEl.appendChild(item);
});
resultsEl.classList.add("open");
}
function setActive(i) {
activeIndex = i;
const items = resultsEl.querySelectorAll(".search-result");
items.forEach((el, idx) => el.classList.toggle("active", idx === i));
const active = resultsEl.querySelector(".search-result.active");
if (active) active.scrollIntoView({ block: "nearest" });
}
function closeResults() {
resultsEl.classList.remove("open");
resultsEl.innerHTML = "";
currentMatches = [];
activeIndex = -1;
}
function selectResult(i) {
const entry = currentMatches[i];
if (!entry) return;
closeResults();
searchInput.value = "";
searchInput.blur();
entry.action();
}
searchInput.addEventListener("input", () => {
if (!searchInput.value.trim()) { closeResults(); return; }
renderResults(filterFeatures(searchInput.value));
});
searchInput.addEventListener("focus", () => {
if (searchInput.value.trim()) renderResults(filterFeatures(searchInput.value));
});
searchInput.addEventListener("keydown", (e) => {
if (!currentMatches.length) {
if (e.key === "Escape") searchInput.blur();
return;
}
if (e.key === "ArrowDown") { e.preventDefault(); setActive((activeIndex + 1) % currentMatches.length); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((activeIndex - 1 + currentMatches.length) % currentMatches.length); }
else if (e.key === "Enter") { e.preventDefault(); if (activeIndex >= 0) selectResult(activeIndex); }
else if (e.key === "Escape") { e.preventDefault(); closeResults(); searchInput.blur(); }
});
searchInput.addEventListener("blur", () => setTimeout(closeResults, 150));
const icon = headerSearch.querySelector(".header-search-icon");
if (icon) icon.addEventListener("click", () => searchInput.focus());
document.addEventListener("click", (e) => {
if (!e.target.closest("#header-search")) closeResults();
});
}
function setup() {
const menu = {
switches: syncedSwitches,
checkboxes: [
"hide_navbar",
"browser_show_likes",
"gpa_calc_weighted",
"gpa_calc_cumulative",
// /*'card_method_date',*/ "show_updates",
"todo_hide_feedback",
"todo_hide_read",
"todo_full_height",
"device_dark",
"relative_dues",
"card_overdues",
"hide_completed_cards",
"equal_height_cards",
// "todo_overdues",
"auto_dark",
"assignment_date_format",
"custom_font_skip_p",
"todo_hr24",
"todo_separate_scrollbar",
"todo_alternate_colors",
"todo_ignore_card_colors",
"todo_remove_icons",
"todo_show_scores",
"grade_hover",
"card_letter",
"auto_detect_disabled",
"hide_course_images",
"anon_fake_todo",
"anon_progress_enabled",
// "hide_completed",
"hover_preview",
"customBackgroundDaily",
"customBackgroundNasaDaily",
"fitImageToScreen",
"card_transparency",
"customCardStyles",
"card_grid",
"card_grid_center_rows",
"card_grid_flex",
"grade_analytics_zones",
],
tabs: {
"advanced-settings": {
setup: displayAdvancedCards,
tab: ".advanced",
},
"custom-font-btn": {
setup: displayCustomFont,
tab: ".custom-font-container",
},
"gpa-bounds-btn": {
setup: null,
tab: ".gpa-bounds-container",
},
"card-colors-btn": { setup: null, tab: ".card-colors-container" },
"customize-dark-btn": {
setup: displayDarkModeFixUrls,
tab: ".customize-dark",
},
"import-export-btn": {
setup: displayThemeList,
tab: ".import-export",
},
"report-issue-btn": {
setup: displayErrors,
tab: ".report-issue-container",
},
"updates-btn": { setup: null, tab: ".updates-container" },
},
special: [
{
identifier: "auto_dark_start",
setup: (initial) =>
setupAutoDarkInput(initial, "auto_dark_start"),
},
{
identifier: "auto_dark_end",
setup: (initial) =>
setupAutoDarkInput(initial, "auto_dark_end"),
},
{
identifier: "num_assignments",
setup: (initial) => setupAssignmentsSlider(initial),
},
{
identifier: "num_todo_items",
setup: (initial) => setupTodoSlider(initial),
},
{
identifier: "sidebar_scale",
setup: (initial) => setupSidebarScaleSlider(initial),
},
{
identifier: "bg_opacity",
setup: (initial) => setupRangeSlider("bg_opacity", "bgOpacitySlider", "bgOpacityValue", "bgOpacityReset", 65, 100, "%", initial),
},
{
identifier: "sidebar_opacity",
setup: (initial) => setupRangeSlider("sidebar_opacity", "sidebarOpacitySlider", "sidebarOpacityValue", "sidebarOpacityReset", 100, 100, "%", initial),
},
{
identifier: "bg_blur",
setup: (initial) => setupRangeSlider("bg_blur", "bgBlurSlider", "bgBlurValue", "bgBlurReset", 8, 30, "px", initial),
},
{
identifier: "sidebar_blur",
setup: (initial) => setupRangeSlider("sidebar_blur", "sidebarBlurSlider", "sidebarBlurValue", "sidebarBlurReset", 0, 30, "px", initial),
},
{
identifier: "card_opacity",
setup: (initial) => setupRangeSlider("card_opacity", "cardOpacitySlider", "cardOpacityValue", "cardOpacityReset", 80, 100, "%", initial),
},
{
identifier: "card_blur",
setup: (initial) => setupRangeSlider("card_blur", "cardBlurSlider", "cardBlurValue", "cardBlurReset", 8, 30, "px", initial),
},
{
identifier: "card_limit",
setup: (initial) => setupCardLimitSlider(initial),
},
{
identifier: "card_method_dashboard",
setup: (initial) => setupDashboardMethod(initial),
},
{
identifier: "custom_styles",
setup: (initial) => setupCustomStyle(initial),
},
{
identifier: "imageSize",
setup: (initial) => setupImageSizeInput(initial),
},
{
identifier: "cardRoundness",
setup: (initial) => setupCardRoundnessInput(initial),
},
{
identifier: "imageRoundness",
setup: (initial) => setupImageRoundnessInput(initial),
},
{
identifier: "cardSpacing",
setup: (initial) => setupCardSpacingInput(initial),
},
{
identifier: "cardWidth",
setup: (initial) => setupCardWidthInput(initial),
},
{
identifier: "cardHeight",
setup: (initial) => setupCardHeightInput(initial),
},
{
identifier: "cardPadding",
setup: (initial) => setupCardPaddingInput(initial),
},
{
identifier: "card_grid",
setup: (initial) => setupCardGrid(initial),
},
{
identifier: "card_grid_columns",
setup: (initial) => setupCardGridCountInput("card_grid_columns", initial),
},
{
identifier: "card_grid_rows",
setup: (initial) => setupCardGridCountInput("card_grid_rows", initial),
},
{
identifier: "card_grid_column_gap",
setup: (initial) => setupCardGridCountInput("card_grid_column_gap", initial, 0),
},
{
identifier: "card_grid_row_gap",
setup: (initial) => setupCardGridCountInput("card_grid_row_gap", initial, 0),
},
{
identifier: "customBackgroundLink",
setup: (initial) => setupCustomBackgroundLink(initial),
},
{
identifier: "customBackgroundScale",
setup: (initial) => setupCustomBackgroundScale(initial),
},
{
identifier: "todo_celebrations",
setup: (initial) => setupCelebrationsSelect(initial),
},
{
identifier: "todo_celebration_amount",
setup: (initial) => setupCelebrationAmount(initial),
},
{
identifier: "celebration_preview",
setup: () => setupCelebrationPreview(),
},
{
identifier: "todo_progress_rings",
setup: (initial) => setupProgressRingsSelect(initial),
},
{
identifier: "todo_timeframe",
setup: (initial) => setupTimeframeSelect(initial),
},
{
identifier: "todo_timeframe_custom_past",
setup: (initial) => setupCustomTimeframeInput("todo_timeframe_custom_past", initial, 7),
},
{
identifier: "todo_timeframe_custom_future",
setup: (initial) => setupCustomTimeframeInput("todo_timeframe_custom_future", initial, 14),
},
{
identifier: "anon_grade_level",
setup: (initial) => setupAnonGradeSelect(initial),
},
{
identifier: "anon_fake_completed",
setup: (initial) => setupAnonFakeCompleted(initial),
},
{
identifier: "anon_progress_done",
setup: (initial) => setupAnonProgressInput("anon_progress_done", initial, true),
},
{
identifier: "anon_progress_total",
setup: (initial) => setupAnonProgressInput("anon_progress_total", initial, false),
},
{
identifier: "widget_row_height",
setup: (initial) => setupWidgetHeightInput("widget_row_height", initial),
},
{
identifier: "dashboard_notes_height",
setup: (initial) => setupWidgetHeightInput("dashboard_notes_height", initial),
},
{
identifier: "gpa_calc_height",
setup: (initial) => setupWidgetHeightInput("gpa_calc_height", initial),
},
{
identifier: "focus_timer_height",
setup: (initial) => setupWidgetHeightInput("focus_timer_height", initial),
},
{
identifier: "breathing_height",
setup: (initial) => setupWidgetHeightInput("breathing_height", initial),
},
{
identifier: "daily_quotes_height",
setup: (initial) => setupWidgetHeightInput("daily_quotes_height", initial),
},
{
identifier: "focus_timer_preset_1",
setup: (initial) => setupWidgetTimeInput("focus_timer_preset_1", initial),
},
{
identifier: "focus_timer_preset_2",
setup: (initial) => setupWidgetTimeInput("focus_timer_preset_2", initial),
},
{
identifier: "focus_timer_preset_3",
setup: (initial) => setupWidgetTimeInput("focus_timer_preset_3", initial),
},
{
identifier: "breathing_preset_1",
setup: (initial) => setupWidgetTimeInput("breathing_preset_1", initial),
},
{
identifier: "breathing_preset_2",
setup: (initial) => setupWidgetTimeInput("breathing_preset_2", initial),
},
{
identifier: "breathing_preset_3",
setup: (initial) => setupWidgetTimeInput("breathing_preset_3", initial),
},
{
identifier: "todo_more_expanded",
setup: (initial) => setupTodoMoreOptions(initial),
},
],
};
chrome.storage.sync.get(menu.switches, sync => {
menu.switches.forEach(option => {
let optionSwitch = document.getElementById(option);
let status = (sync[option] !== undefined ? sync[option] === true : defaultOptions.sync[option] === true) ? "#on" : "#off";
optionSwitch.querySelector(status).checked = true;
optionSwitch.querySelector(status).classList.add('checked');
optionSwitch.querySelector(".slider").addEventListener("mouseup", () => {
let status = !optionSwitch.querySelector("#on").checked;
optionSwitch.querySelector("#on").checked = status;
optionSwitch.querySelector("#on").classList.toggle("checked");
optionSwitch.querySelector("#off").classList.toggle("checked");
chrome.storage.sync.set({ [option]: status });
if (option === "auto_dark") {
toggleDarkModeDisable(status);
}
if (option === "better_sidebar") {
toggleBetterSidebarSubOptions(status);
}
if (option === "better_todo") {
toggleCelebrationsVisibility(status);
}
if (option === "todo_celebrations_enabled") {
toggleCelebrationOptionsVisibility(status);
}
if (option === "dark_mode") {
toggleAlternateColorsVisibility(status);
}
if (option === "hide_personal_details" && status === false) {
// "Temporarily remove all course images" is a sub-option of
// the privacy mode: switching the mode off restores the
// images too (the content script listens for this key and
// removes its hiding stylesheet).
const imgCheckbox = document.getElementById("hide_course_images");
if (imgCheckbox) imgCheckbox.checked = false;
chrome.storage.sync.set({ "hide_course_images": false });
}
toggleSubOptionsVisibility(option, status);
if (option === "dashboard_notes" || option === "gpa_calc" || option === "focus_timer" || option === "breathing" || option === "daily_quotes") {
if (refreshWidgetOrder) refreshWidgetOrder();
}
});
});
toggleBetterSidebarSubOptions(sync["better_sidebar"] === true);
toggleCelebrationsVisibility(sync["better_todo"] === true);
chrome.storage.sync.get("todo_celebrations_enabled", v => {
toggleCelebrationOptionsVisibility(v["todo_celebrations_enabled"] !== false);
});
["dashboard_notes", "gpa_calc", "focus_timer", "breathing", "daily_quotes", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => {
toggleSubOptionsVisibility(opt, sync[opt] === true);
});
toggleAlternateColorsVisibility(sync["dark_mode"] === true);
});
setupWidgetPosition();
setupWidgetOrder();
chrome.storage.sync.get(menu.checkboxes, sync => {
menu.checkboxes.forEach(option => {
const checkbox = document.querySelector("#" + option);
if (!checkbox) {console.log(option); return;}
checkbox.addEventListener("change", function (e) {
let status = this.checked;
if (option === "customBackgroundDaily" && status) {
document.querySelector("#customBackgroundNasaDaily").checked = false;
chrome.storage.sync.set({ "customBackgroundDaily": true, "customBackgroundNasaDaily": false });
} else if (option === "customBackgroundNasaDaily" && status) {
document.querySelector("#customBackgroundDaily").checked = false;
chrome.storage.sync.set({ "customBackgroundNasaDaily": true, "customBackgroundDaily": false });
} else {
chrome.storage.sync.set(JSON.parse(`{"${option}": ${status}}`));
}
if (option === "anon_fake_todo") {
toggleAnonFakeCompletedVisibility(status);
}
syncCustomBackgroundDailyState(document.querySelector("#customBackgroundDaily")?.checked === true || document.querySelector("#customBackgroundNasaDaily")?.checked === true);
toggleOpacityOptions();
});
const value = sync[option] !== undefined ? sync[option] : defaultOptions.sync[option];
document.querySelector("#" + option).checked = value;
});
syncCustomBackgroundDailyState(sync.customBackgroundDaily === true || sync.customBackgroundNasaDaily === true);
toggleAnonFakeCompletedVisibility(sync["anon_fake_todo"] === true);
toggleDarkModeDisable(sync.auto_dark);
displayBackgroundPresets();
toggleOpacityOptions();
});
const specialOptions = menu.special.map(obj => obj.identifier);
chrome.storage.sync.get(specialOptions, sync => {
console.log(sync);
menu.special.forEach(option => {
if (option.setup !== null) {
const value = sync[option.identifier] !== undefined ? sync[option.identifier] : defaultOptions.sync[option.identifier];
option.setup(value);
}
});
})
// activate tab buttons
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
if (menu.tabs[btn.id].setup !== null) menu.tabs[btn.id].setup();
document.querySelector(".main").style.display = "none";
document.querySelector(menu.tabs[btn.id].tab).style.display = "block";
window.scrollTo(0, 0);
});
});
// activate the back buttons on each tab
document.querySelectorAll(".back-btn").forEach(btn => {
btn.addEventListener("click", function () {
document.querySelectorAll(".tab").forEach(tab => {
tab.style.display = "none";
});
document.querySelector(".main").style.display = "block";
});
});
// give everything the appropirate i18n text
document.querySelectorAll('[data-i18n]').forEach(text => {
const msg = chrome.i18n.getMessage(text.dataset.i18n);
// keep the in-HTML fallback text when a key is missing instead of blanking it
if (msg) text.innerText = msg;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const msg = chrome.i18n.getMessage(el.dataset.i18nPlaceholder);
if (msg) el.placeholder = msg;
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const msg = chrome.i18n.getMessage(el.dataset.i18nTitle);
if (msg) el.title = msg;
});
// header feature search (search bar that jumps to features)
setupFeatureSearch(menu);
// activate dark mode inspector button
document.querySelector("#inspector-btn").addEventListener("click", async function () {
document.querySelector("#inspector-output").textContent = (await sendFromPopup("inspect"))["selectors"];
});
// activate dark mode fixer button
document.querySelector("#fix-dm-btn").addEventListener("click", async function () {
let output = await sendFromPopup("fixdm");
if (output.path === "canvasrefined-none" || output.path === "canvasrefined-darkmode_off") return;
let rating = "bad";
if (output.time < 100) {
rating = "good";
} else if (output.time < 250) {
rating = "ok";
}
document.getElementById("fix-dm-output").textContent = "Fix took " + Math.round(output.time) + "ms (rating: " + rating + ")";
chrome.storage.sync.get("dark_mode_fix", sync => {
if (sync["dark_mode_fix"].includes(output.path)) return;
sync["dark_mode_fix"].push(output.path);
chrome.storage.sync.set({ "dark_mode_fix": sync["dark_mode_fix"] }).then(() => displayDarkModeFixUrls());
})
});
// activate storage dump button
document.querySelector("#rk_btn").addEventListener("click", () => {
chrome.storage.local.get(null, local => {
chrome.storage.sync.get(null, sync => {
document.querySelector("#rk_output").value = JSON.stringify(local) + JSON.stringify(sync);
})
})
});
// activate storage reset button
document.querySelector("#storage-reset-btn").addEventListener("click", () => {
chrome.storage.sync.set(defaultOptions["sync"]);
updateStorageUsage();
});
// "Reroll classes" (Report issue tab → Privacy). Bumps the fake-data seed;
// content scripts watch the key, so every open Canvas tab re-shuffles its
// fake courses/assignments/grades without a refresh.
document.querySelector("#anon-reroll-btn").addEventListener("click", () => {
chrome.storage.sync.set({ "anon_grade_seed": Math.floor(Math.random() * 1000000) });
displayAlert(false, "Fake classes rerolled.");
});
// activate planner cache clear button (Report issue tab). Clears the
// cached planner items locally, then tells any open Canvas tabs to
// re-fetch fresh data so the clear takes effect without a refresh.
// Keep the key in sync with PLANNER_CACHE_KEY in content.js.
document.querySelector("#planner-cache-clear-btn").addEventListener("click", async () => {
displayAlert(false, "Planner cache cleared.");
try {
await chrome.storage.local.remove("planner_cache_v1");
} catch (e) { /* nothing stored yet */ }
await sendFromPopup("clearPlannerCache");
updateStorageUsage();
});
// ---- Storage usage display (Report issue tab) ----
// Shows how much space the extension's settings, planner cache, and
// other local data take, plus the storage quota available to it.
function formatStorageBytes(bytes) {
if (bytes == null) return "unknown";
if (bytes < 1024) return bytes + " B";
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / 1048576).toFixed(2) + " MB";
}
// getBytesInUse is exact but not implemented everywhere (older Firefox),
// so fall back to measuring the serialized data as a Blob.
async function storageBytesInUse(area, keys) {
try {
const bytes = await area.getBytesInUse(keys ?? null);
if (typeof bytes === "number") return bytes;
} catch (e) { /* not supported */ }
try {
const data = await area.get(keys ?? null);
return new Blob([JSON.stringify(data)]).size;
} catch (e) {
return null;
}
}
async function updateStorageUsage() {
const set = (id, text) => {
const el = document.querySelector(id);
if (el) el.textContent = text;
};
// Sync settings vs the 100 KB chrome.storage.sync quota.
const syncBytes = await storageBytesInUse(chrome.storage.sync);
set("#storage-usage-sync", syncBytes == null ? "unknown" : formatStorageBytes(syncBytes) + " of 100 KB quota");
// Planner cache: size plus item count, so users can see what the
// to-do cache actually costs (and that clearing it worked).
const cacheBytes = await storageBytesInUse(chrome.storage.local, "planner_cache_v1");
let cacheText = "empty";
if (cacheBytes) {
try {
const result = await chrome.storage.local.get("planner_cache_v1");
const count = result?.["planner_cache_v1"]?.items?.length;
cacheText = formatStorageBytes(cacheBytes) + (typeof count === "number" ? ` (${count} items)` : "");
} catch (e) {
cacheText = formatStorageBytes(cacheBytes);
}
}
set("#storage-usage-cache", cacheText);
// Everything else in local storage (settings the extension keeps
// locally, state like the analytics toggle, etc.).
const localBytes = await storageBytesInUse(chrome.storage.local);
if (localBytes == null) {
set("#storage-usage-local", "unknown");
} else {
const other = cacheBytes != null ? Math.max(localBytes - cacheBytes, 0) : null;
set("#storage-usage-local", other == null ? formatStorageBytes(localBytes) : formatStorageBytes(other));
}
// Quota available to the extension. With the unlimitedStorage
// permission this is a large slice of free disk space, so it reads
// big — that is expected.
try {
const est = await navigator.storage.estimate();
if (est && est.quota) {
set("#storage-usage-quota", formatStorageBytes(est.quota) + " available");
}
} catch (e) { /* estimate unsupported */ }
}
updateStorageUsage();
// Canvas URL list — each school gets its own text box
const domainListEl = document.querySelector("#custom-domain-list");
const addDomainBtn = document.querySelector("#add-domain-btn");
// Normalize a single entry: a full URL becomes its hostname, anything else
// is kept as typed. Idempotent — rows store bare hostnames
// ("canvas.school.edu"), which fail new URL() without a scheme, so they're
// validated as hostnames instead of re-flagged as invalid on every save.
function sanitizeDomainEntry(val, showAlert = true) {
let trimmed = val.trim().replace(/ /g, "");
if (trimmed === "") return "";
try {
const host = new URL(trimmed).hostname;
if (host) {
clearAlert();
return host;
}
} catch (e) { /* not a full URL — may still be a bare hostname */ }
// strip scheme/path if new URL couldn't parse it, then validate as a hostname
const host = trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "");
if (host && /^[a-z0-9][a-z0-9.-]*$/i.test(host)) {
clearAlert();
return host;
}
if (showAlert) displayAlert(true, "The URL you entered appears to be invalid, so it might not work.");
return trimmed;
}
function saveDomainList() {
const domains = [];
domainListEl.querySelectorAll(".domain-row input").forEach(input => {
const val = sanitizeDomainEntry(input.value);
if (val !== "") domains.push(val);
});
// keep the legacy [""]-when-empty shape the rest of the code expects
chrome.storage.sync.set({ custom_domain: domains.length ? domains : [""] });
}
function makeDomainRow(value) {
const row = document.createElement("div");
row.className = "domain-row";
const input = document.createElement("input");
input.type = "text";
input.placeholder = "canvas.myschool.edu";
input.value = value || "";
input.spellcheck = false;
input.addEventListener("input", saveDomainList);
input.addEventListener("blur", () => {
// normalize what's shown and drop rows left empty
if (input.value.trim() === "") {
if (domainListEl.querySelectorAll(".domain-row").length > 1) row.remove();
} else {
input.value = sanitizeDomainEntry(input.value);
}
saveDomainList();
});
const remove = document.createElement("button");
remove.className = "domain-remove";
remove.type = "button";
remove.textContent = "\u2715";
remove.title = "Remove";
remove.addEventListener("click", () => {
const val = sanitizeDomainEntry(input.value, false);
row.remove();
saveDomainList();
// clear any past "no thanks" for this canvas so the detection
// prompt can ask again if it's visited later
if (val) {
chrome.storage.sync.get(["custom_domain_denied"], storage => {
const denied = (storage.custom_domain_denied || []).filter(d => d !== val);
chrome.storage.sync.set({ custom_domain_denied: denied });
});
}
});
row.appendChild(input);
row.appendChild(remove);
return row;
}
chrome.storage.sync.get(["custom_domain"], storage => {
const domains = (storage.custom_domain || []).filter(d => d && d.trim() !== "");
domains.forEach(d => domainListEl.appendChild(makeDomainRow(d)));
});
addDomainBtn.addEventListener("click", () => {
domainListEl.appendChild(makeDomainRow(""));
const rows = domainListEl.querySelectorAll(".domain-row input");
rows[rows.length - 1].focus();
});
// activate import input box
document.querySelector("#import-input").addEventListener("input", (e) => {
const obj = JSON.parse(e.target.value);
importTheme(obj);
});
// copy export output to clipboard
document.querySelector("#export-copy").addEventListener("click", async () => {
const output = document.querySelector("#export-output");
const btn = document.querySelector("#export-copy");
const text = output.value;
if (!text) { displayAlert(true, "Nothing to copy — select what to export first."); return; }
const flash = () => { const old = btn.textContent; btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = old; }, 1200); };
try {
await navigator.clipboard.writeText(text);
flash();
} catch (e) {
output.select();
document.execCommand("copy");
flash();
}
});
// activate export checkbox
document.querySelectorAll(".export-details input").forEach(input => {
input.addEventListener("change", () => {
chrome.storage.sync.get(syncedSwitches.concat(syncedSubOptions).concat(["dark_preset", "custom_cards", "custom_font", "gpa_calc_bounds", "custom_styles"]), async storage => {
let final = {};
for await (item of document.querySelectorAll(".export-details input")) {
if (item.checked) {
switch (item.id) {
case "export-toggles":
final = { ...final, ...(await getExport(storage, exportToggles)) };
break;
case "export-dark":
final = { ...final, ...(await getExport(storage, ["dark_preset", "dark_mode"])) };
break;
case "export-dark-schedule":
final = { ...final, ...(await getExport(storage, exportDarkSchedule)) };
break;
case "export-cards":
final = { ...final, ...(await getExport(storage, ["custom_cards"])) };
break;
case "export-colors":
final = { ...final, ...(await getExport(storage, ["card_colors", ...exportCardColorToggles])) };
break;
case "export-card-styles":
final = { ...final, ...(await getExport(storage, exportCardStyles)) };
break;
case "export-customStyles": {
// custom_styles is stored in storage.local
// (see setupCustomStyle); fall back to the
// legacy sync value for pre-migration themes.
const localStyles = await chrome.storage.local.get("custom_styles");
const styleSource = localStyles && localStyles["custom_styles"] !== undefined ? localStyles : storage;
final = { ...final, ...(await getExport(styleSource, ["custom_styles"])) };
break;
}
case "export-font":
final = { ...final, ...(await getExport(storage, ["custom_font", "custom_font_skip_p"])) };
break;
case "export-background":
final = { ...final, ...(await getExport(storage, exportBackground)) };
break;
case "export-todo":
final = { ...final, ...(await getExport(storage, exportTodo)) };
break;
case "export-gpa":
final = { ...final, ...(await getExport(storage, [...exportGpa, "gpa_calc_bounds"])) };
break;
}
}
}
document.querySelector("#export-output").value = JSON.stringify(final);
});
});
});
// Toggle all export checkboxes and dispatch one change event to refresh output.
const exportCheckboxes = () => document.querySelectorAll(".export-details input");
const refreshExport = () => {
const first = exportCheckboxes()[0];
if (first) first.dispatchEvent(new Event("change", { bubbles: true }));
};
document.querySelector("#export-check-all")?.addEventListener("click", () => {
exportCheckboxes().forEach(c => { c.checked = true; });
refreshExport();
});
document.querySelector("#export-check-none")?.addEventListener("click", () => {
exportCheckboxes().forEach(c => { c.checked = false; });
refreshExport();
});
// activate revert to original button
document.querySelector("#theme-revert").addEventListener("click", () => {
chrome.storage.local.get("previous_theme", local => {
if (local["previous_theme"] !== null) {
importTheme(local["previous_theme"]);
}
});
});
document.querySelector("#alert").addEventListener("click", clearAlert);
// TODO: maybe fix this at some point, idk what is causing the error here
document.querySelectorAll(".preset-button.customization-button").forEach(btn => btn.addEventListener("click", changeToPresetCSS));
// activate card color inputs
document.querySelector("#singleColorInput").addEventListener("change", e => document.querySelector("#singleColorText").value = e.target.value);
document.querySelector("#singleColorText").addEventListener("change", e => document.querySelector("#singleColorInput").value = e.target.value);
document.querySelector("#gradientColorFrom").addEventListener("change", e => document.querySelector("#gradientColorFromText").value = e.target.value);
document.querySelector("#gradientColorFromText").addEventListener("change", e => document.querySelector("#gradientColorFrom").value = e.target.value);
document.querySelector("#gradientColorTo").addEventListener("change", e => document.querySelector("#gradientColorToText").value = e.target.value);
document.querySelector("#gradientColorToText").addEventListener("change", e => document.querySelector("#gradientColorTo").value = e.target.value);
document.querySelector("#setSingleColor").addEventListener("click", () => {
let colors = [document.querySelector("#singleColorInput").value];;
sendFromPopup("setcolors", colors);
});
document.querySelector("#setGradientColor").addEventListener("click", () => {
chrome.storage.sync.get("custom_cards", sync => {
length = 0;
Object.keys(sync["custom_cards"]).forEach(key => {
if (sync["custom_cards"][key].hidden !== true) length++;
});
let colors = [];
let from = document.querySelector("#gradientColorFrom").value;
let to = document.querySelector("#gradientColorTo").value;
for (let i = 1; i <= length; i++) {
colors.push(getColorInGradient(i / length, from, to));
}
sendFromPopup("setcolors", colors);
});
});
// activate revert to original card colors button
document.querySelector("#revert-colors").addEventListener("click", () => {
chrome.storage.local.get("previous_colors", local => {
const prev = local["previous_colors"];
// Guard against unset/expired-shape entries and the empty lists
// old list-mode runs stored — sending [] would be a silent no-op.
if (prev && Array.isArray(prev.colors) && prev.colors.length > 0) {
sendFromPopup("setcolors", prev.colors);
}
})
})
// activate every card color palette button
document.querySelectorAll(".preset-button.colors-button").forEach(btn => {
const colors = getPalette(btn.querySelector("p").textContent);
let preview = btn.querySelector(".colors-preview");
colors.forEach(color => {
let div = makeElement("div", preview, { "className": "color-preview"});
div.style.background = color;
});
btn.addEventListener("click", () => {
sendFromPopup("setcolors", colors);
})
});
// activate sidebar tool radio
["#radio-sidebar-image", "#radio-sidebar-gradient", "#radio-sidebar-solid"].forEach(radio => {
document.querySelector(radio).addEventListener("click", () => {
chrome.storage.sync.get(["dark_preset"], storage => {
let mode = radio === "#radio-sidebar-image" ? "image" : radio === "#radio-sidebar-gradient" ? "gradient" : "solid";
displaySidebarMode(mode, storage["dark_preset"]["sidebar"]);
});
})
});
// theme browser controls
document.getElementById("premade-themes-left").addEventListener("click", () => changePage(-1));
document.getElementById("premade-themes-right").addEventListener("click", () => changePage(1));
document.getElementById("theme-sorts").addEventListener("click", () => {
const el = document.getElementById("theme-sort-selector");
if (el.classList.contains("open")) {
clickout();
} else {
el.classList.add("open");
setTimeout(() => {
document.addEventListener("click", clickout);
}, 10);
}
});
document.getElementById("theme-search").addEventListener("change", async (e) => {
searchFor = e.target.value;
console.log(searchFor);
// linear search
let themesToShow = [];
for (let i = 0; i < themes.length; i++) {
if (themes[i].title.toLowerCase().includes(searchFor.toLowerCase())) {
themesToShow.push(themes[i]);
}
}
console.log(themesToShow);
displayThemeSearchList(themesToShow);
});
// activate theme save button
document.getElementById("save-theme").addEventListener("click", saveCurrentTheme);
// activate submit theme button
document.getElementById("submit-theme-btn-1").addEventListener("click", () => {
document.getElementById("submit-popup").classList.add("open");
});
document.getElementById("cancel-theme-btn").addEventListener("click", () => {
document.getElementById("submit-popup").classList.remove("open");
})
// update theme button preview on input
document.getElementById("submit-title").addEventListener("input", (e) => {
document.getElementById("theme-button-title-preview").textContent = e.target.value.replaceAll(" ", "");
});
// update theme button preview on input
document.getElementById("submit-credits").addEventListener("input", (e) => {
document.getElementById("theme-button-creator-preview").textContent = e.target.value;
});
// activate the show button to open the theme submission drawer
document.getElementById("show-submit-form").addEventListener("click", (e) => {
const drawer = document.getElementById("submit-drawer");
if (drawer.style.display === "none") {
drawer.style.display = "block";
e.target.textContent = "Hide";
} else {
drawer.style.display = "none";
e.target.textContent = "Show";
}
});
// activate theme browser opt out
// activate theme browser opt in
document.querySelectorAll(".theme-sort-btn").forEach(btn => {
btn.addEventListener("click", (e) => {
themeSort(e.target.textContent);
});
});
// browser settings buttons
document.getElementById("browser-settings-btn").addEventListener("click", () => {
document.getElementById("browser-settings-popup").classList.add("open");
});
document.getElementById("close-settings-btn").addEventListener("click", () => {
displayThemeList(0);
document.getElementById("browser-settings-popup").classList.remove("open");
});
document.getElementById("submit-form-btn").addEventListener("click", displayThemeSubmissionForm);
// Note: the card-style number inputs (imageSize, cardRoundness,
// imageRoundness, cardSpacing, cardWidth, cardHeight, cardPadding) are wired
// via their setup*Input
// handlers in menu.special above, which debounce the storage writes. The
// duplicate listeners that used to live here referenced nonexistent
// #*Value spans (threw on every input) and double-wrote to storage, which
// is what blew the sync write quota when adjusting card styles quickly.
document.getElementById("clearCustomBackground").addEventListener("click", () => {
chrome.storage.sync.set({ "customBackgroundLink": "", "customBackgroundScale": 100 });
document.querySelector("#customBackgroundLink").value = "";
document.querySelector("#customBackgroundScale").value = 100;
document.querySelector("#customBackgroundScaleValue").textContent = "100%";
renderBackgroundPresetSelection();
sendFromPopup("updateBackground");
toggleOpacityOptions();
});
const applyFontsDropdownState = (isOpen) => {
const el = document.getElementById("quick-fonts");
const el2 = document.getElementsByClassName("custom-font")[0];
const arrow = document.getElementById("fontsDropdownArrow");
if (!el || !el2 || !arrow) return;
el.style.display = isOpen ? "flex" : "none";
el2.style.display = isOpen ? "block" : "none";
arrow.style.transform = isOpen ? "rotate(180deg)" : "rotate(0deg)";
};
chrome.storage.local.get([fontsDropdownStateKey], (storage) => {
const isOpen = storage[fontsDropdownStateKey] !== false;
applyFontsDropdownState(isOpen);
});
document.getElementById("fontsDropdown").addEventListener("click", () => {
const el = document.getElementById("quick-fonts");
const el2 = document.getElementsByClassName("custom-font")[0];
if (!el || !el2) return;
const isCurrentlyOpen = getComputedStyle(el).display !== "none" && getComputedStyle(el2).display !== "none";
const nextOpen = !isCurrentlyOpen;
applyFontsDropdownState(nextOpen);
chrome.storage.local.set({ [fontsDropdownStateKey]: nextOpen });
});
// The "Keep original assignment font" toggle sits inline in the dropdown
// header; clicking it must not also collapse/expand the font dropdown.
const skipPToggle = document.getElementById("custom_font_skip_p");
if (skipPToggle) {
skipPToggle.closest("span").addEventListener("click", (e) => e.stopPropagation());
}
}
function setupCustomStyle(initial) {
const el = document.getElementById("custom-styles");
// Custom CSS lives in storage.local (unlimitedStorage) — storage.sync's
// 8KB per-item quota silently dropped long stylesheets. `initial` is the
// legacy sync value, used until the user saves again (migrated to local
// on first save; content.js also self-migrates on page load).
chrome.storage.local.get("custom_styles", local => {
el.value = local && local["custom_styles"] !== undefined ? local["custom_styles"] : (initial || "");
});
el.addEventListener("change", (e) => {
chrome.storage.local.set({ "custom_styles": e.target.value });
});
}
function displayThemeSubmissionForm() {
document.getElementById("submit-form").style.display = "block";
document.getElementById("view-submissions").style.display = "none";
document.getElementById("submit-form-btn").classList.add("active");
document.getElementById("view-submissions-btn").classList.remove("active");
}
async function getExport(storage, options) {
let final = {};
for (const option of options) {
switch (option) {
case "custom_cards":
let arr = [];
Object.keys(storage["custom_cards"]).forEach(key => {
const img = storage["custom_cards"][key].img;
if (img && img !== "none" && img.trim() !== "") arr.push(img);
});
final["custom_cards"] = arr;
break;
case "card_colors":
final["card_colors"] = [];
try {
final["card_colors"] = await sendFromPopup("getcolors");
} catch (e) {
console.log(e);
}
break;
case "custom_styles":
final["custom_styles"] = storage["custom_styles"];
break;
default:
final[option] = storage[option];
}
}
return final;
}
let pageTimeout = false;
function changePage(direction) {
if (pageTimeout) return;
pageTimeout = true;
displayThemeList(direction);
setTimeout(() => {
pageTimeout = false;
}, 500);
}
const colorValues = {
"red": 1,
"pink": 2,
"orange": 3,
"yellow": 4,
"lightgreen": 5,
"green": 6,
"lightblue": 7,
"blue": 8,
"lightpurple": 9,
"purple": 10,
"lightpurple": 11,
"beige": 12,
"brown": 13,
"gray": 14,
}
function themeSortFn(method) {
let themes = getTheme("all");
switch (method) {
case "New":
return themes.reverse();
case "Old":
return themes;
case "Color":
return themes.sort((a, b) => {
return (colorValues[a.color] || (a.color !== "whiteblack" && a.color.includes("white") ? 15 : 16)) - (colorValues[b.color] || (b.color !== "whiteblack" && b.color.includes("white") ? 15 : 16))
})
return themes.sort((a, b) => {
return a.color < b.color ? 1 : -1;
})
case "ABC":
return themes.sort((a, b) => {
return a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1;
})
default:
return shuffle(themes).sort((a, b) => {
a = a.score + "";
b = b.score + "";
a = parseInt(a.charAt(0)) + parseInt(a.charAt(1)) + parseInt(a.charAt(2)) + parseInt(a.charAt(3));
b = parseInt(b.charAt(0)) + parseInt(b.charAt(1)) + parseInt(b.charAt(2)) + parseInt(b.charAt(3));
return b - a;
});
}
}
let cache = {};
// new theme sort button
function themeSort(sort) {
current_sort = sort;
current_page_num = 1;
allThemes = themeSortFn(current_sort);
displayThemeList(0);
}
function clickout() {
setTimeout(() => {
document.getElementById("theme-sort-selector").classList.remove("open");
document.removeEventListener("click", clickout);
}, 10);
}
// shuffle function for the score sorting so theres no order bias
function shuffle (arr) {
var j, x, index;
for (index = arr.length - 1; index > 0; index--) {
j = Math.floor(Math.random() * (index + 1));
x = arr[index];
arr[index] = arr[j];
arr[j] = x;
}
return arr;
}
let current_page_num = 1;
let maxPage = 0;
let searchFor = "";
let current_sort = "Popular";
let allThemes = themeSortFn(current_sort);
let fallback = false;
function saveCurrentTheme() {
const allOptions = syncedSwitches.concat(syncedSubOptions).concat(["dark_preset", "custom_cards", "custom_font", "gpa_calc_bounds", "card_colors", "custom_styles"]);
chrome.storage.local.get(["saved_themes", "custom_styles"], local => {
chrome.storage.sync.get(allOptions, async sync => {
// custom_styles is stored in storage.local (sync's 8KB per-item
// quota drops long stylesheets); merge it over the legacy sync one.
if (local["custom_styles"] !== undefined) sync["custom_styles"] = local["custom_styles"];
let current = await getExport(sync, allOptions);
let trimmed = {
"disable_color_overlay": current["disable_color_overlay"],
"gradient_cards": current["gradient_cards"],
"dark_mode": current["dark_mode"],
"dark_preset": current["dark_preset"],
"custom_cards": current["custom_cards"],
"card_colors": current["card_colors"] === null ? [current["dark_preset"]["links"]] : current["card_colors"],
"custom_font": current["custom_font"],
"custom_font_skip_p": current["custom_font_skip_p"],
"better_todo": current["better_todo"],
"todo_hide_feedback": current["todo_hide_feedback"],
"todo_hide_read": current["todo_hide_read"] !== false,
"todo_full_height": current["todo_full_height"],
"todo_celebrations_enabled": current["todo_celebrations_enabled"],
"todo_celebrations": current["todo_celebrations"],
"todo_celebration_amount": current["todo_celebration_amount"],
"todo_progress_rings": current["todo_progress_rings"],
"todo_timeframe": current["todo_timeframe"],
"todo_timeframe_custom_past": current["todo_timeframe_custom_past"],
"todo_timeframe_custom_future": current["todo_timeframe_custom_future"],
"todo_hr24": current["todo_hr24"],
"todo_separate_scrollbar": current["todo_separate_scrollbar"],
"todo_alternate_colors": current["todo_alternate_colors"],
"todo_ignore_card_colors": current["todo_ignore_card_colors"],
"todo_remove_icons": current["todo_remove_icons"],
"todo_show_scores": current["todo_show_scores"],
"better_sidebar": current["better_sidebar"],
"sidebar_scale": current["sidebar_scale"],
"imageSize": current["imageSize"],
"cardRoundness": current["cardRoundness"],
"imageRoundness": current["imageRoundness"],
"cardSpacing": current["cardSpacing"],
"cardWidth": current["cardWidth"],
"cardHeight": current["cardHeight"],
"cardPadding": current["cardPadding"],
// Card Grid (part of card styles): layout toggle, counts,
// spacing and uneven-row centering.
"card_grid": current["card_grid"],
"card_grid_columns": current["card_grid_columns"],
"card_grid_rows": current["card_grid_rows"],
"card_grid_column_gap": current["card_grid_column_gap"],
"card_grid_row_gap": current["card_grid_row_gap"],
"card_grid_center_rows": current["card_grid_center_rows"],
"custom_styles": current["custom_styles"],
"customCardStyles": current["customCardStyles"],
"customBackgroundLink": current["customBackgroundLink"],
"customBackgroundScale": current["customBackgroundScale"],
"customBackgroundDaily": current["customBackgroundDaily"],
"customBackgroundNasaDaily": current["customBackgroundNasaDaily"],
"fitImageToScreen": current["fitImageToScreen"],
"bg_opacity": current["bg_opacity"],
"sidebar_opacity": current["sidebar_opacity"],
"bg_blur": current["bg_blur"],
"sidebar_blur": current["sidebar_blur"],
}
const now = new Date();
local["saved_themes"][now.getTime()] = trimmed;
chrome.storage.local.set({ "saved_themes": local["saved_themes"] }).then(() => {
displaySavedThemes();
});
});
});
}
function displayThemeList(direction = 0) {
// Render the sorted theme list, filtered by any active search.
let themesToShow = allThemes;
if (searchFor) {
themesToShow = allThemes.filter(theme => theme.title.toLowerCase().includes(searchFor.toLowerCase()));
}
displayThemeSearchList(themesToShow, direction);
}
function displayThemeSearchList(themesToShow, pageDir = 0) {
document.getElementById("theme-current-sort").textContent = current_sort;
const perPage = 24;
const maxPage = Math.ceil(themesToShow.length / perPage);
if (pageDir == -1 && current_page_num > 1) current_page_num--;
if (pageDir == 1 && current_page_num < maxPage) current_page_num++;
let container = document.getElementById("premade-themes");
container.textContent = "";
let start = (current_page_num - 1) * perPage, end = start + perPage;
themesToShow.forEach((theme, index) => {
if (index < start || index >= end) return;
let themeBtn = makeElement("button", container, { "className": "theme-button" });
themeBtn.classList.add("customization-button");
if (!themeBtn.style.background) themeBtn.style.backgroundImage = "linear-gradient(#00000070, #00000070), url(" + theme.preview + ")";
let split = theme.title.split(" by ");
makeElement("p", themeBtn, {"className": "theme-button-title", "textContent": split[0] });
makeElement("p", themeBtn, {"className": "theme-button-creator", "textContent": split[1] });
themeBtn.addEventListener("click", () => {
const allOptions = syncedSwitches.concat(syncedSubOptions).concat(["dark_preset", "custom_cards", "custom_font", "gpa_calc_bounds", "card_colors", "custom_styles"]);
chrome.storage.sync.get(allOptions, sync => {
chrome.storage.local.get(["previous_theme", "custom_styles"], async local => {
if (local["previous_theme"] === null) {
// custom_styles is stored in storage.local; merge it
// in so saved themes keep the user's CSS.
if (local["custom_styles"] !== undefined) sync["custom_styles"] = local["custom_styles"];
let previous = await getExport(sync, allOptions);
chrome.storage.local.set({ "previous_theme": previous });
}
importTheme(theme.exports);
});
});
});
}
)
}
function getRelativeDate(date, short = false) {
let now = new Date();
let timeSince = (now.getTime() - date.getTime()) / 60000;
let time = "min";
timeSince = Math.abs(timeSince);
if (timeSince >= 60) {
timeSince /= 60;
time = short ? "h" : "hour";
if (timeSince >= 24) {
timeSince /= 24;
time = short ? "d" : "day";
if (timeSince >= 7) {
timeSince /= 7;
time = short ? "w" : "week";
}
}
}
timeSince = Math.round(timeSince);
let relative = timeSince + (short ? "" : " ") + time + (timeSince > 1 && !short ? "s" : "");
return { time: relative, ms: now.getTime() - date.getTime() };
}
function displaySavedThemes() {
chrome.storage.local.get("saved_themes", local => {
const target = document.getElementById("saved-themes");
target.textContent = "";
Object.keys(local["saved_themes"]).forEach((key, index) => {
const created = new Date(parseInt(key));
let btn = makeElement("div", target, { "className": "saved-theme" });
let title = makeElement("p", btn, { "className": "theme-button-title", "textContent": `Theme ${index + 1}`});
let date = makeElement("p", btn, { "className": "theme-button-creator", "textContent": `${getRelativeDate(created).time} ago` });
let remove = makeElement("div", btn, { "className": "theme-button-remove", "textContent": "x" });
btn.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.44), rgba(0, 0, 0, 0.44)), url(${local["saved_themes"][key]["custom_cards"]?.[0] || ""})`;
btn.addEventListener("click", () => {
importTheme(local["saved_themes"][key]);
});
remove.addEventListener("click", () => {
chrome.storage.local.get("saved_themes", local => {
delete local["saved_themes"][key];
chrome.storage.local.set({ "saved_themes": local["saved_themes"] }).then(() => {
btn.remove();
})
})
});
});
});
}
function getTheme(name) {
// get localThemes from themes.js
console.log(themes);
if (name === "all") return themes;
for (const theme in themes) if (theme.title === name) return theme
return {};
}
function importTheme(theme) {
try {
let keys = Object.keys(theme);
let final = {};
let localFinal = null;
chrome.storage.sync.get("custom_cards", sync => {
keys.forEach(key => {
switch (key) {
case "dark_preset":
changeToPresetCSS(null, theme["dark_preset"]);
break;
case "card_colors":
sendFromPopup("setcolors", theme["card_colors"]);
break;
case "custom_cards": {
// "none" is a legacy placeholder meaning "this theme has
// no custom card images" — treat it as such instead of
// writing the literal string into every card (which hides
// the card image on the dashboard). Fall back to "" so
// cards keep their default Canvas images.
const themeImgs = (theme["custom_cards"] || []).filter(u => u && u !== "none" && u.trim() !== "");
if (theme["custom_cards"].length > 0) {
let pos = 0;
Object.keys(sync["custom_cards"]).forEach(key => {
sync["custom_cards"][key].img = themeImgs.length ? themeImgs[pos] : "";
pos = (pos === themeImgs.length - 1) ? 0 : pos + 1;
});
final["custom_cards"] = sync["custom_cards"];
}
break;
}
case "todo_confetti":
// Legacy setting: map the old confetti toggle onto the
// celebrations list ("false" meant celebrations off).
final["todo_celebrations"] = theme["todo_confetti"] === false ? [] : ["confetti"];
break;
default:
final[key] = theme[key];
break;
}
});
// custom_styles must go to storage.local — sync's 8KB per-item
// quota rejects (or truncates) large stylesheets from themes.
if (Object.prototype.hasOwnProperty.call(final, "custom_styles")) {
localFinal = { "custom_styles": final["custom_styles"] };
delete final["custom_styles"];
}
chrome.storage.sync.set(final);
if (localFinal) chrome.storage.local.set(localFinal);
});
} catch (e) {
console.log(e);
}
}
function updateCards(key, value) {
chrome.storage.sync.get(["custom_cards"], result => {
chrome.storage.sync.set({ "custom_cards": { ...result["custom_cards"], [key]: { ...result["custom_cards"][key], ...value } } }, () => {
if (chrome.runtime.lastError) {
displayAlert(true, "The data you're entering is exceeding the storage limit, so it won't save. Try using shorter links, and make sure to press \"copy image address\" and not \"copy image\" for links.");
}
})
});
}
function displayCustomFont() {
chrome.storage.sync.get(["custom_font"], storage => {
let el = document.querySelector(".custom-font");
let linkContainer = document.querySelector(".custom-font-flex") || makeElement("div", el, {"className": "custom-font-flex" });
linkContainer.innerHTML = '<span>https://fonts.googleapis.com/css2?family=</span><input class="card-input" id="custom-font-link"></input>';
let link = linkContainer.querySelector("#custom-font-link");
link.value = storage.custom_font.link;
link.addEventListener("change", function (e) {
let linkVal = e.target.value.split(":")[0];
let familyVal = linkVal.replace("+", " ");
linkVal += linkVal === "" ? "" : ":wght@400;700";
familyVal = linkVal === "" ? "" : "'" + familyVal + "'";
chrome.storage.sync.set({ "custom_font": { "link": linkVal, "family": familyVal } });
link.value = linkVal;
});
const popularFonts = ["Arimo", "Barriecito", "Barlow", "Caveat", "Cinzel", "Comfortaa", "Corben", "DM Sans", "Expletus Sans", "Gluten", "Happy Monkey", "Inconsolata", "Inria Sans", "Jost", "Kanit", "Karla", "Kode Mono", "Lobster", "Lora", "Madimi One", "Mali", "Montserrat", "Nanum Myeongjo", "Open Sans", "Oswald", "Permanent Marker", "Playfair Display", "Poetsen One", "Poppins", "Quicksand", "Rakkas", "Redacted Script", "Roboto Mono", "Rubik", "Silkscreen", "Sixtyfour", "Syne Mono", "Tektur", "Texturina", "Ysabeau Infant", "Yuji Syuku"];
let quickFonts = document.querySelector("#quick-fonts");
quickFonts.textContent = "";
let noFont = makeElement("button", quickFonts, { "className": "customization-button", "textContent": "None" });
noFont.addEventListener("click", () => {
chrome.storage.sync.set({ "custom_font": { "link": "", "family": "" } });
link.value = "";
})
popularFonts.forEach(font => {
let btn = makeElement("button", quickFonts, { "className":"customization-button", "textContent": font });
btn.addEventListener("click", () => {
let linkVal = font.replace(" ", "+") + ":wght@400;700";
chrome.storage.sync.set({ "custom_font": { "link": linkVal, "family": "'" + font + "'" } });
link.value = linkVal;
});
});
});
}
let removeAlert = null;
function clearAlert() {
clearTimeout(removeAlert);
document.querySelector("#alert").style.bottom = "-400px";
}
function displayAlert(bad, msg) {
clearTimeout(removeAlert);
document.querySelector("#alert").style.bottom = "0";
document.querySelector("#alert").textContent = msg;
document.querySelector("#alert").style.background = bad ? "#e7495ed9" : "#468b46d9";
removeAlert = setTimeout(() => {
clearAlert();
}, 15000);
}
function setCustomImage(key, val) {
if (val !== "" && val !== "none") {
let test = new Image();
test.onerror = () => {
displayAlert(true, "It seems that the image link you provided isn't working. Make sure to right click on any images you want to use and select \"copy image address\" to get the correct link.");
updateCards(key, { "img": val });
}
test.onload = clearAlert;
test.src = val;
}
updateCards(key, { "img": val });
}
async function displayAdvancedCards() {
// Sync from Canvas only when needed: when there are no cards yet (fresh
// install) or when older cards predate the stored fullName field. Once
// every card has a fullName this is a no-op and the grid renders instantly.
const needsSync = await new Promise(resolve => {
chrome.storage.sync.get("custom_cards", s => {
const c = s.custom_cards || {};
const ids = Object.keys(c);
resolve(!ids.length || ids.some(id => !c[id].fullName));
});
});
if (needsSync) await sendFromPopup("getCards");
chrome.storage.sync.get(["custom_cards", "custom_cards_2"], storage => {
const cardGrid = document.getElementById("card-grid");
if (!cardGrid) {
console.error("Card grid element not found");
return;
}
cardGrid.innerHTML = "";
const customCards = storage.custom_cards || {};
const customCards2 = storage.custom_cards_2 || {};
const allCards = {};
Object.keys(customCards).forEach((courseId) => {
allCards[courseId] = {
...customCards[courseId],
...(customCards2[courseId] || {}),
};
});
Object.keys(customCards2).forEach((courseId) => {
if (!allCards[courseId]) {
allCards[courseId] = customCards2[courseId];
}
});
if (Object.keys(allCards).length === 0) {
cardGrid.innerHTML = '<p style="text-align: center; color: #999; padding: 20px;">No course cards found. Visit your Canvas dashboard to load courses.</p>';
return;
}
Object.keys(allCards).forEach(courseId => {
const course = allCards[courseId];
if (course && typeof course === 'object') {
const courseButton = createCourseButton(courseId, course);
cardGrid.appendChild(courseButton);
}
});
// showCardEditMenu sets an inline display:none on the grid; clear it
// so reopening the menu shows the cards again (CSS default = grid).
cardGrid.style.display = "";
const editMenu = document.getElementById("card-edit-menu");
if (editMenu) {
editMenu.style.display = "none";
}
});
}
const CARD_EYE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-eye"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" /></svg>';
function createCourseButton(courseId, courseData) {
const button = document.createElement("button");
button.className = "course-card-button";
const displayName =
courseData.name ||
courseData.fullName ||
courseData.default ||
courseData.code ||
`Course ${courseId}`;
button.textContent = displayName;
button.dataset.courseId = courseId;
if (courseData.img || courseData.hidden || courseData.hide || courseData.hide_todo) {
button.classList.add("customized");
}
button.addEventListener("click", () => {
document.querySelectorAll(".course-card-button").forEach(btn => btn.classList.remove("active"));
button.classList.add("active");
showCardEditMenu(courseId, courseData);
});
const tile = document.createElement("div");
tile.className = "course-card-tile";
tile.classList.toggle("card-hidden", courseData.hidden === true);
tile.appendChild(button);
makeElement("button", tile, { className: "course-card-hide-toggle", title: "Hide/show on dashboard", innerHTML: CARD_EYE_SVG }).addEventListener("click", () => {
const hidden = courseData.hidden !== true;
courseData.hidden = courseData.hide = hidden;
updateCards(courseId, { hidden, hide: hidden });
tile.classList.toggle("card-hidden", hidden);
});
return tile;
}
function showCardEditMenu(courseId, courseData) {
const editMenu = document.getElementById("card-edit-menu");
const cardGrid = document.getElementById("card-grid");
if (cardGrid) cardGrid.style.display = "none";
editMenu.style.display = "block";
const displayName =
courseData.name ||
courseData.fullName ||
courseData.default ||
courseData.code ||
`Course ${courseId}`;
editMenu.innerHTML = `
<div class="card-edit-header">
<h3 class="card-edit-title">${displayName}</h3>
<button class="card-close-btn" id="card-close-btn">close</button>
</div>
<div class="card-edit-section">
<label class="card-edit-label">Custom Name</label>
<input type="text" class="card-input" id="card-name-input"
value="${courseData.name || ""}" placeholder="Enter custom course name">
</div>
<div class="card-edit-section">
<label class="card-edit-label">Custom Code</label>
<input type="text" class="card-input" id="card-code-input"
value="${courseData.code || ""}" placeholder="Enter custom course code">
</div>
<div class="card-edit-section">
<label class="card-edit-label">Card Image URL</label>
<input type="text" class="card-input" id="card-image-input"
value="${courseData.img || ""}" placeholder="Enter image URL or 'none'">
<div class="card-image-preview" id="card-image-preview"></div>
</div>
<div class="card-edit-section">
<label class="card-edit-label">Hide Card</label>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="card-hide-input" ${courseData.hidden || courseData.hide ? "checked" : ""}>
<span>Hide this card from dashboard</span>
</div>
</div>
<div class="card-edit-section">
<label class="card-edit-label">Better Todo List</label>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="card-todo-hidden-input" ${courseData.hide_todo === true ? "checked" : ""}>
<span>Hide this course from the Better Todo list</span>
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="big-button" id="save-card-btn">Save Changes</button>
<button class="customization-button" id="reset-card-btn">Reset to Default</button>
</div>
`;
document
.getElementById("card-close-btn")
.addEventListener("click", hideCardEditMenu);
document
.getElementById("save-card-btn")
.addEventListener("click", () => saveCardChanges(courseId));
document
.getElementById("reset-card-btn")
.addEventListener("click", () => resetCardToDefault(courseId));
updateImagePreview();
document
.getElementById("card-image-input")
.addEventListener("input", updateImagePreview);
}
function updateImagePreview() {
const imageInput = document.getElementById("card-image-input");
const preview = document.getElementById("card-image-preview");
if (imageInput && preview) {
const imageUrl = imageInput.value;
if (imageUrl && imageUrl !== "none" && imageUrl.trim() !== "") {
preview.style.backgroundImage = `url(${imageUrl})`;
preview.style.display = "block";
} else {
preview.style.backgroundImage = "none";
preview.style.display = "none";
}
}
}
function saveCardChanges(courseId) {
const nameInput = document.getElementById("card-name-input");
const codeInput = document.getElementById("card-code-input");
const imageInput = document.getElementById("card-image-input");
const hideInput = document.getElementById("card-hide-input");
const todoHiddenInput = document.getElementById("card-todo-hidden-input");
const updates = {
name: nameInput.value,
code: codeInput.value,
img: imageInput.value,
hidden: hideInput.checked,
hide: hideInput.checked,
hide_todo: todoHiddenInput.checked,
};
if (imageInput.value !== "" && imageInput.value !== "none") {
setCustomImage(courseId, imageInput.value);
} else {
updateCards(courseId, updates);
}
displayAlert(false, "Card settings saved successfully!");
hideCardEditMenu();
setTimeout(() => {
displayAdvancedCards();
}, 500);
}
function resetCardToDefault(courseId) {
updateCards(courseId, { name: "", code: "", img: "", hidden: false, hide: false, hide_todo: false });
displayAlert(false, "Card reset to default settings!");
setTimeout(() => {
displayAdvancedCards();
}, 500);
}
function hideCardEditMenu() {
const editMenu = document.getElementById("card-edit-menu");
const cardGrid = document.getElementById("card-grid");
if (editMenu) editMenu.style.display = "none";
if (cardGrid) cardGrid.style.display = "grid";
document
.querySelectorAll(".course-card-button")
.forEach((btn) => btn.classList.remove("active"));
}
function toggleDarkModeDisable(disabled) {
let darkSwitch = document.querySelector('#dark_mode');
if (disabled === true) {
darkSwitch.classList.add('switch_disabled');
darkSwitch.style.pointerEvents = "none";
} else {
darkSwitch.classList.remove('switch_disabled');
darkSwitch.style.pointerEvents = "auto";
}
}
// customization tab
function getPalette(name) {
const colors = {
"Blues": ["#ade8f4", "#90e0ef", "#48cae4", "#00b4d8", "#0096c7"],
"Reds": ["#e01e37", "#c71f37", "#b21e35", "#a11d33", "#6e1423"],
"Rainbow": ["#ff0000", "#ff5200", "#efea5a", "#3cf525", "#147df5", "#be0aff"],
"Candy": ["#cdb4db", "#ffc8dd", "#ffafcc", "#bde0fe", "#a2d2ff"],
"Purples": ["#e0aaff", "#c77dff", "#9d4edd", "#7b2cbf", "#5a189a"],
"Pastels": ["#fff1e6", "#fde2e4", "#fad2e1", "#bee1e6", "#cddafd"],
"Ocean": ["#22577a", "#38a3a5", "#57cc99", "#80ed99", "#c7f9cc"],
"Sunset": ["#eaac8b", "#e56b6f", "#b56576", "#6d597a", "#355070"],
"Army": ["#6b705c", "#a5a58d", "#b7b7a4", "#ffe8d6", "#ddbea9", "#cb997e"],
"Pinks": ["#ff0a54", "#ff5c8a", "#ff85a1", "#ff99ac", "#fbb1bd"],
"Watermelon": ["#386641", "#6a994e", "#a7c957", "#f2e8cf", "#bc4749"],
"Popsicle": ["#70d6ff", "#ff70a6", "#ff9770", "#ffd670", "#e9ff70"],
"Chess": ["#ffffff", "#000000"],
"Greens": ["#d8f3dc", "#b7e4c7", "#95d5b2", "#74c69d", "#52b788"],
"Fade": ["#ff69eb", "#ff86c8", "#ffa3a5", "#ffbf81", "#ffdc5e"],
"Oranges": ["#ffc971", "#ffb627", "#ff9505", "#e2711d", "#cc5803"],
"Mesa": ["#f6bd60", "#f28482", "#f5cac3", "#84a59d", "#f7ede2"],
"Berries": ["#4cc9f0", "#4361ee", "#713aed", "#9348c3", "#f72585"],
"Fade2": ["#f2f230", "#C2F261", "#91f291", "#61F2C2", "#30f2f2"],
"Muted": ["#E7E6F7", "#E3D0D8", "#AEA3B0", "#827081", "#C6D2ED"],
"Base": ["#e3b505", "#95190C", "#610345", "#107E7D", "#044B7F"],
"Fruit": ["#7DDF64", "#C0DF85", "#DEB986", "#DB6C79", "#ED4D6E"],
"Night": ["#25171A", "#4B244A", "#533A7B", "#6969B3", "#7F86C6"]
}
return colors[name] || [];
}
function componentToHex(c) {
var hex = c.toString(16);
return hex.length == 1 ? "0" + hex : hex;
}
function getColorInGradient(d, from, to) {
let pat = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
var exec1 = pat.exec(from);
var exec2 = pat.exec(to);
let a1 = [parseInt(exec1[1], 16), parseInt(exec1[2], 16), parseInt(exec1[3], 16)];
let a2 = [parseInt(exec2[1], 16), parseInt(exec2[2], 16), parseInt(exec2[3], 16)];
let rgb = a1.map((x, i) => Math.floor(a1[i] + d * (a2[i] - a1[i])));
return "#" + componentToHex(rgb[0]) + componentToHex(rgb[1]) + componentToHex(rgb[2]);
}
function displaySidebarMode(mode, style) {
style = style.replace(" ", "");
let match = style.match(/linear-gradient\((?<color1>\#\w*),(?<color2>\#\w*)\)/);
let c1 = c2 = "#000000";
if (mode === "image") {
document.querySelector("#radio-sidebar-image").checked = true;
document.querySelector("#sidebar-color2").style.display = "flex";
document.querySelector("#sidebar-image").style.display = "flex";
if (style.includes("url") && match) {
if (match.groups.color1) c1 = match.groups.color1.replace("c7", "");
if (match.groups.color2) c2 = match.groups.color2.replace("c7", "");
}
let url = style.match(/url\(\"(?<url>.*)\"\)/);
document.querySelector('#sidebar-image input[type="text"]').value = url && url.groups.url ? url.groups.url : "";
} else if (mode === "gradient") {
document.querySelector("#radio-sidebar-gradient").checked = true;
document.querySelector("#sidebar-color2").style.display = "flex";
document.querySelector("#sidebar-image").style.display = "none";
if (!style.includes("url") && match) {
if (match.groups.color1) c1 = match.groups.color1;
if (match.groups.color2) c2 = match.groups.color2;
}
} else {
document.querySelector("#radio-sidebar-solid").checked = true;
document.querySelector("#sidebar-color2").style.display = "none";
document.querySelector("#sidebar-image").style.display = "none";
c1 = match ? "#000000" : style;
}
document.querySelector('#sidebar-color1 input[type="text"]').value = c1;
document.querySelector('#sidebar-color1 input[type="color"]').value = c1;
document.querySelector('#sidebar-color2 input[type="text"]').value = c2;
document.querySelector('#sidebar-color2 input[type="color"]').value = c2;
}
let presetChangeTimeout = null;
chrome.storage.sync.get(["dark_preset"], storage => {
let tab = document.querySelector(".customize-dark");
Object.keys(storage["dark_preset"]).forEach(key => {
if (key !== "sidebar") {
let c = tab.querySelector("#dp_" + key);
let color = c.querySelector('input[type="color"]');
let text = c.querySelector('input[type="text"]');
[color, text].forEach(changer => {
changer.value = storage["dark_preset"][key];
changer.addEventListener("input", function (e) {
clearTimeout(presetChangeTimeout);
presetChangeTimeout = setTimeout(() => changeCSS(key, e.target.value), 200);
});
});
} else {
let mode = storage["dark_preset"][key].includes("url") ? "image" : storage["dark_preset"][key].includes("gradient") ? "gradient" : "solid";
displaySidebarMode(mode, storage["dark_preset"][key]);
let changeSidebar = () => {
let c1 = tab.querySelector('#sidebar-color1 input[type="text"]').value.replace("c7", "");
let c2 = tab.querySelector('#sidebar-color2 input[type="text"]').value.replace("c7", "");
let url = tab.querySelector('#sidebar-image input[type="text"]').value;
if (tab.querySelector("#radio-sidebar-image").checked) {
changeCSS(key, `linear-gradient(${c1}c7, ${c2}c7), center url("${url}")`);
} else if (tab.querySelector("#radio-sidebar-gradient").checked) {
changeCSS(key, `linear-gradient(${c1}, ${c2})`);
} else {
changeCSS(key, c1);
}
}
["#sidebar-color1", "#sidebar-color2"].forEach(group => {
['input[type="text"]', 'input[type="color"]'].forEach(input => {
document.querySelector(group + " " + input).addEventListener("input", e => {
['input[type="text"]', 'input[type="color"]'].forEach(i => {
document.querySelector(group + " " + i).value = e.target.value;
});
clearTimeout(presetChangeTimeout);
presetChangeTimeout = setTimeout(() => changeSidebar(), 200);
});
});
});
document.querySelector('#sidebar-image input[type="text"').addEventListener("change", () => changeSidebar());
}
});
});
function refreshColors() {
chrome.storage.sync.get(["dark_preset"], storage => {
Object.keys(storage["dark_preset"]).forEach(key => {
let c = document.querySelector("#dp_" + key);
let color = c.querySelector('input[type="color"]');
let text = c.querySelector('input[type="text"]');
color.value = storage["dark_preset"][key];
text.value = storage["dark_preset"][key];
});
let mode = storage["dark_preset"]["sidebar"].includes("url") ? "image" : storage["dark_preset"]["sidebar"].includes("gradient") ? "gradient" : "solid";
displaySidebarMode(mode, storage["dark_preset"]["sidebar"]);
});
}
function changeCSS(name, color) {
chrome.storage.sync.get("dark_preset", storage => {
storage["dark_preset"][name] = color;
chrome.storage.sync.set({ "dark_preset": storage["dark_preset"] }).then(() => refreshColors());
});
}
function changeToPresetCSS(e, preset = null) {
const presets = {
"dark-lighter": { "background-0": "#272727", "background-1": "#353535", "background-2": "#404040", "borders": "#454545", "sidebar": "#353535", "text-0": "#f5f5f5", "text-1": "#e2e2e2", "text-2": "#ababab", "links": "#56Caf0", "sidebar-text": "#f5f5f5" },
"dark-light": { "background-0": "#202020", "background-1": "#2e2e2e", "background-2": "#4e4e4e", "borders": "#404040", "sidebar": "#2e2e2e", "text-0": "#f5f5f5", "text-1": "#e2e2e2", "text-2": "#ababab", "links": "#56Caf0", "sidebar-text": "#f5f5f5" },
"dark-default": { "background-0": "#161616", "background-1": "#1e1e1e", "background-2": "#262626", "borders": "#3c3c3c", "text-0": "#f5f5f5", "text-1": "#e2e2e2", "text-2": "#ababab", "links": "#56Caf0", "sidebar": "#1e1e1e", "sidebar-text": "#f5f5f5" },
"dark-dark": { "background-0": "#101010", "background-1": "#121212", "background-2": "#1a1a1a", "borders": "#272727", "sidebar": "#121212", "text-0": "#f5f5f5", "text-1": "#e2e2e2", "text-2": "#ababab", "links": "#56Caf0", "sidebar-text": "#f5f5f5" },
"dark-darker": { "background-0": "#000000", "background-1": "#000000", "background-2": "#000000", "borders": "#000000", "sidebar": "#000000", "text-0": "#c5c5c5", "text-1": "#c5c5c5", "text-2": "#c5c5c5", "links": "#c5c5c5", "sidebar-text": "#c5c5c5" },
"dark-blue": { "background-0": "#14181d", "background-1": "#1a2026", "background-2": "#212930", "borders": "#2e3943", "sidebar": "#1a2026", "text-0": "#f5f5f5", "text-1": "#e2e2e2", "text-2": "#ababab", "links": "#56Caf0", "sidebar-text": "#f5f5f5" },
"dark-mint": { "background-0": "#0f0f0f", "background-1": "#0c0c0c", "background-2": "#141414", "borders": "#1e1e1e", "sidebar": "#0c0c0c", "text-0": "#f5f5f5", "text-1": "#e2e2e2", "text-2": "#ababab", "links": "#7CF3CB", "sidebar-text": "#f5f5f5" },
"dark-burn": { "background-0": "#ffffff", "background-1": "#ffffff", "background-2": "#ffffff", "borders": "#cccccc", "sidebar": "#ffffff", "text-0": "#cccccc", "text-1": "#cccccc", "text-2": "#cccccc", "links": "#cccccc", "sidebar-text": "#cccccc" },
"dark-unicorn": { "background-0": "#ff6090", "background-1": "#00C1FF", "background-2": "#FFFF00", "borders": "#FFFF00", "sidebar": "#00C1FF", "text-0": "#ffffff", "text-1": "#ffffff", "text-2": "#ffffff", "links": "#000000", "sidebar-text": "#ffffff" },
"dark-lightmode": { "background-0": "#ffffff", "background-1": "#f5f5f5", "background-2": "#d4d4d4", "borders": "#c7cdd1", "links": "#04ff00", "sidebar": "#04ff00", "sidebar-text": "#ffffff", "text-0": "#2d3b45", "text-1": "#919191", "text-2": "#a5a5a5" },
"dark-catppuccin": { "background-0": "#11111b", "background-1": "#181825", "background-2": "#1e1e2e", "borders": "#4f5463", "text-0": "#cdd6f4", "text-1": "#7f849c", "text-2": "#a6e3a1", "links": "#f5c2e7", "sidebar": "#181825", "sidebar-text": "#7f849c" },
"dark-sage": { "background-0": "#2f3e46", "background-1": "#354f52", "background-2": "#52796f", "borders": "#84a98c", "links": "#d8f5c7", "sidebar": "#354f52", "sidebar-text": "#e2e8de", "text-0": "#e2e8de", "text-1": "#cad2c5", "text-2": "#adb1aa" },
"dark-pink": { "background-0": "#ffffff", "background-1": "#ffe0ed", "background-2": "#ff0066", "borders": "#ff007b", "links": "#ff0088", "sidebar": "#f490b3", "sidebar-text": "#ffffff", "text-0": "#ff0095", "text-1": "#ff8f8f", "text-2": "#ff5c5c" },
"dark-coral": {"background-0":"#131c26","background-1":"#0e1721","background-2":"#151c24","borders":"#0e1721","links":"#f88379","sidebar":"#131c26","sidebar-text":"#f88379","text-0":"#f88379","text-1":"#f88379","text-2":"#f88379"},
}
if (preset === null) preset = presets[e.target.id] || presets["default"];
applyPreset(preset);
}
function applyPreset(preset) {
chrome.storage.sync.set({ "dark_preset": preset }).then(() => refreshColors());
}
function makeElement(element, location, options) {
let creation = document.createElement(element);
Object.keys(options).forEach(key => {
creation[key] = options[key];
});
location.appendChild(creation);
return creation
}
async function sendFromPopup(message, options = {}) {
let response = new Promise((resolve, reject) => {
chrome.tabs.query({ currentWindow: true }).then(async tabs => {
for (let i = 0; i < tabs.length; i++) {
try {
let res = await chrome.tabs.sendMessage(tabs[i].id, { "message": message, "options": options });
if (res) resolve(res);
} catch (e) {
}
}
resolve(null);
});
})
return await response;
}