diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index cbd0ecf..23cba9f 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -140,6 +140,30 @@
"widget_short_gpa": {
"message": "GPA"
},
+ "focus_timer": {
+ "message": "Focus Timer"
+ },
+ "breathing_breaks": {
+ "message": "Breathing Breaks"
+ },
+ "music": {
+ "message": "Music"
+ },
+ "daily_quotes": {
+ "message": "Daily Quotes"
+ },
+ "widget_short_focus": {
+ "message": "Focus"
+ },
+ "widget_short_music": {
+ "message": "Music"
+ },
+ "widget_short_breathing": {
+ "message": "Breathing"
+ },
+ "widget_short_quotes": {
+ "message": "Quotes"
+ },
"grade_settings": {
"message": "Grade Settings"
},
diff --git a/css/content.css b/css/content.css
index 51d8d08..36f66b7 100644
--- a/css/content.css
+++ b/css/content.css
@@ -70,7 +70,7 @@
.canvasrefined-course-percent, .canvasrefined-course-credit {border: 1px solid #ccc; color: var(--ic-brand-font-color-dark)}
.canvasrefined-course-percent, .canvasrefined-course-weight, .canvasrefined-course-credit {
- height: 22px; text-align: center;border-radius: 3px;width: 50px; font-size: 13px; padding: 0;
+ height: 22px; text-align: center;border-radius: 3px;width: 150px; font-size: 13px; padding: 0;
}
.canvasrefined-course-weight {
@@ -548,3 +548,145 @@
.cr-gpa-body {grid-template-columns:1fr;}
.cr-gpa-pane-scale {border-left:none;border-top:1px solid var(--bcborders,#c7cdd1);}
}
+
+/* ===== Generic dashboard widgets: Focus Timer, Music, Breathing, Quotes =====
+ Shared shell follows the same card look as Notes/GPA: 262px grid cell with
+ the 36px Canvas gutter when placed before/after the course cards, and a
+ full-width flowing card when placed in the top/bottom container row. */
+.canvasrefined-widget-card {
+ width: 262px;
+ min-height: 200px;
+ box-sizing: border-box;
+ background: var(--bcbackground-1,#fff);
+ color: var(--bctext-0,#2d3b45);
+ border-radius: 4px;
+ box-shadow: 0 2px 5px rgba(0,0,0,.3);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ font-size: 13px;
+}
+.canvasrefined-widget-card.in-cards {
+ margin: 36px 0 0 36px;
+ display: inline-flex;
+ vertical-align: top;
+}
+.canvasrefined-widget-card.container-mode {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+.canvasrefined-widget-slot .canvasrefined-widget-card {width:100%;margin:0;height:100%;}
+.cr-widget-head {display:flex;align-items:center;justify-content:space-between;padding:12px 14px 10px;border-bottom:1px solid var(--bcborders,#c7cdd1);}
+.cr-widget-title {font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);}
+.cr-widget-btn {flex:1;border:none;border-radius:8px;padding:9px 12px;font:inherit;font-weight:600;font-size:13px;cursor:pointer;background:var(--ic-brand-primary,#0374b5);color:#fff;}
+.cr-widget-btn:hover {filter:brightness(1.07);}
+.cr-widget-btn-ghost {background:transparent;color:var(--bctext-0,#2d3b45);border:1px solid var(--bcborders,#c7cdd1);}
+/* Timer engine (Focus Timer + Breathing Breaks) */
+.cr-time-box {position:relative;margin:14px 14px 0;border-radius:12px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 6%, transparent);border:1px solid var(--bcborders,#c7cdd1);overflow:hidden;}
+/* The whole time rectangle is the progress bar, tinted with the dark-mode
+ link color so it matches the active theme in both light and dark mode. */
+.cr-time-box .cr-timer-progress-bar {position:absolute;left:0;top:0;bottom:0;width:0;background:color-mix(in srgb, var(--bclinks,#0374b5) 32%, transparent);border-right:2px solid color-mix(in srgb, var(--bclinks,#0374b5) 80%, transparent);}
+.cr-timer-time {position:relative;z-index:1;font-size:44px;font-weight:700;text-align:center;letter-spacing:.02em;font-variant-numeric:tabular-nums;padding:22px 14px;line-height:1;color:var(--bctext-0,#2d3b45);}
+.cr-breath-display {padding:12px 14px 6px;}
+.cr-timer-presets {display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px 14px 0;}
+.cr-timer-preset {border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-0,#2d3b45);border-radius:8px;padding:8px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;}
+.cr-timer-preset:hover {border-color:var(--bctext-2,#9aa3ab);}
+.cr-timer-preset.active {background:color-mix(in srgb, var(--bclinks,#0374b5) 18%, transparent);border-color:var(--bclinks,#0374b5);color:var(--bclinks,#0374b5);}
+.cr-timer-custom-row {display:flex;gap:8px;padding:10px 14px 0;align-items:center;}
+.cr-timer-custom-row[hidden] {display:none;}
+.cr-time-fields {display:flex;gap:6px;flex:1;min-width:0;}
+.cr-tf-box {display:flex;align-items:center;gap:2px;flex:1;min-width:0;border:1px solid var(--bcborders,#c7cdd1);border-radius:8px;padding:4px 8px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 5%, transparent);}
+.cr-tf-box:focus-within {border-color:var(--bclinks,#0374b5);box-shadow:0 0 0 2px color-mix(in srgb, var(--bclinks,#0374b5) 22%, transparent);}
+.cr-tf {width:100%;min-width:0;border:none;background:transparent;color:var(--bctext-0,#2d3b45);font:inherit;font-size:14px;font-weight:600;text-align:center;padding:2px 0;}
+.cr-tf:focus {outline:none;}
+.cr-tf::-webkit-outer-spin-button,.cr-tf::-webkit-inner-spin-button {-webkit-appearance:none;margin:0;}
+.cr-tf-unit {font-size:10px;font-weight:700;color:var(--bctext-2,#9aa3ab);text-transform:uppercase;}
+.cr-timer-custom-row .cr-widget-btn {flex:none;padding:8px 14px;}
+.cr-timer-controls {display:flex;gap:8px;padding:12px 14px 14px;}
+/* Breathing Breaks: 4s in / 6s out, colour shifts green -> blue when exhaling */
+.canvasrefined-breathing {background:linear-gradient(160deg, color-mix(in srgb, #6ea8ff 14%, var(--bcbackground-1,#fff)), var(--bcbackground-1,#fff));}
+.cr-breath-stage {display:flex;align-items:center;justify-content:center;padding:20px 14px 6px;}
+.cr-breath-circle {position:relative;width:112px;height:112px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 40%, #74e3c4, #2fb595);box-shadow:0 0 18px 2px rgba(65,209,175,.4);}
+.cr-breath-circle::before {content:"";position:absolute;inset:0;border-radius:50%;background:radial-gradient(circle at 50% 40%, #93a8ff, #536ee0);opacity:0;box-shadow:0 0 26px 5px rgba(101,124,255,.45);}
+.canvasrefined-breathing.is-running .cr-breath-circle {animation: cr-breath-pulse 10s ease-in-out infinite, cr-breath-glow 10s ease-in-out infinite;}
+.canvasrefined-breathing.is-running .cr-breath-circle::before {animation: cr-breath-tint 10s ease-in-out infinite;}
+@keyframes cr-breath-pulse {
+ 0% {transform:scale(.68);}
+ 40% {transform:scale(1);}
+ 100% {transform:scale(.68);}
+}
+@keyframes cr-breath-glow {
+ 0% {box-shadow:0 0 16px 2px rgba(65,209,175,.35);}
+ 40% {box-shadow:0 0 36px 9px rgba(65,209,175,.65);}
+ 100% {box-shadow:0 0 16px 2px rgba(65,209,175,.35);}
+}
+@keyframes cr-breath-tint {
+ 0%,38% {opacity:0;}
+ 60% {opacity:.95;}
+ 100% {opacity:0;}
+}
+.cr-breath-phase,.cr-breath-idle {position:absolute;z-index:1;font-size:12px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.32);}
+.cr-breath-phase {opacity:0;}
+.cr-breath-idle {opacity:1;}
+.canvasrefined-breathing.is-running .cr-breath-idle {opacity:0;}
+.canvasrefined-breathing.is-running .cr-breath-phase-in {animation: cr-breath-in-label 10s ease-in-out infinite;}
+.canvasrefined-breathing.is-running .cr-breath-phase-out {animation: cr-breath-out-label 10s ease-in-out infinite;}
+@keyframes cr-breath-in-label {0%{opacity:1;}38%{opacity:1;}44%{opacity:0;}100%{opacity:0;}}
+@keyframes cr-breath-out-label {0%{opacity:0;}38%{opacity:0;}44%{opacity:1;}94%{opacity:1;}100%{opacity:0;}}
+/* Music: mini player + slide-in library switcher (visual only) */
+.cr-music-list-btn {display:inline-flex;align-items:center;gap:5px;border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-0,#2d3b45);border-radius:999px;padding:4px 10px;font:inherit;font-size:11px;font-weight:600;cursor:pointer;}
+.cr-music-list-btn:hover {border-color:var(--bctext-2,#9aa3ab);}
+.cr-music-caret {transition:transform .2s;}
+.canvasrefined-music.is-drawer-open .cr-music-caret {transform:rotate(180deg);}
+.canvasrefined-music {position:relative;min-height:320px;}
+.canvasrefined-music.container-mode {min-height:320px;}
+.cr-player {display:flex;flex-direction:column;gap:12px;padding:16px 14px 10px;flex:1;justify-content:center;}
+.cr-player-thumb {display:none;position:relative;height:104px;border-radius:12px;overflow:hidden;background:linear-gradient(135deg, color-mix(in srgb, var(--cr-thumb-color,#0374b5) 82%, #000 8%), color-mix(in srgb, var(--cr-thumb-color,#0374b5) 38%, var(--bcbackground-1,#fff)));}
+.cr-player-thumb-mark {position:absolute;right:10px;bottom:8px;color:rgba(255,255,255,.55);line-height:0;}
+.canvasrefined-music.thumb-bg .cr-player-thumb {display:block;}
+.canvasrefined-music.thumb-bg .cr-player-art {display:none;}
+.canvasrefined-music.no-thumb .cr-player-thumb,
+.canvasrefined-music.no-thumb .cr-player-art {display:none;}
+.cr-player-body {display:flex;align-items:center;gap:12px;}
+.cr-player-art {flex:none;width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb, var(--bclinks,#0374b5) 16%, transparent);color:var(--bclinks,#0374b5);}
+.cr-player-info {min-width:0;flex:1;}
+.cr-player-title {font-size:14px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.cr-player-sub {font-size:11.5px;color:var(--bctext-2,#9aa3ab);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.cr-player-progress {height:5px;margin:0 14px;border-radius:999px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 12%, transparent);overflow:hidden;}
+.cr-player-progress-fill {height:100%;width:0;background:var(--bclinks,#0374b5);border-radius:999px;}
+.cr-player-times {display:flex;justify-content:space-between;padding:5px 16px 0;font-size:10.5px;color:var(--bctext-2,#9aa3ab);font-variant-numeric:tabular-nums;}
+.cr-player-controls {display:flex;align-items:center;justify-content:center;gap:16px;padding:10px 14px 14px;}
+.cr-player-btn {display:flex;align-items:center;justify-content:center;width:36px;height:36px;border:none;border-radius:50%;background:transparent;color:var(--bctext-0,#2d3b45);cursor:pointer;}
+.cr-player-btn:hover {background:color-mix(in srgb, var(--bctext-0,#2d3b45) 8%, transparent);}
+.cr-player-play {width:46px;height:46px;background:var(--bclinks,#0374b5);color:#fff;}
+.cr-player-play:hover {background:var(--bclinks,#0374b5);filter:brightness(1.08);}
+/* Slide-in library drawer: platforms / playlists / songs */
+.cr-music-drawer {position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;background:var(--bcbackground-1,#fff);color:var(--bctext-0,#2d3b45);transform:translateX(102%);transition:transform .28s ease;visibility:hidden;box-shadow:-10px 0 26px rgba(0,0,0,.2);}
+.canvasrefined-music.is-drawer-open .cr-music-drawer {transform:translateX(0);visibility:visible;}
+.cr-drawer-head {display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--bcborders,#c7cdd1);}
+.cr-drawer-title {font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);}
+.cr-drawer-close {border:none;background:transparent;color:var(--bctext-2,#9aa3ab);font-size:20px;line-height:1;cursor:pointer;padding:0 5px;border-radius:6px;}
+.cr-drawer-close:hover {color:var(--bctext-0,#2d3b45);background:color-mix(in srgb, var(--bctext-0,#2d3b45) 8%, transparent);}
+.cr-drawer-tabs {display:flex;gap:6px;padding:8px 12px;border-bottom:1px solid var(--bcborders,#c7cdd1);}
+.cr-drawer-tab {flex:1;border:1px solid transparent;background:transparent;color:var(--bctext-2,#9aa3ab);border-radius:999px;padding:6px 8px;font:inherit;font-size:11.5px;font-weight:600;cursor:pointer;}
+.cr-drawer-tab:hover {color:var(--bctext-0,#2d3b45);}
+.cr-drawer-tab.active {background:color-mix(in srgb, var(--bclinks,#0374b5) 16%, transparent);color:var(--bclinks,#0374b5);border-color:color-mix(in srgb, var(--bclinks,#0374b5) 45%, transparent);}
+.cr-drawer-body {flex:1;overflow-y:auto;padding:8px 10px 12px;}
+.cr-drawer-pane[hidden] {display:none;}
+.cr-drawer-row {display:flex;align-items:center;gap:10px;padding:8px;border-radius:9px;}
+.cr-drawer-row:hover {background:color-mix(in srgb, var(--bctext-0,#2d3b45) 6%, transparent);}
+.cr-drawer-icon {flex:none;display:flex;line-height:0;}
+.cr-drawer-icon-pl {opacity:.85;}
+.cr-drawer-meta {display:flex;flex-direction:column;min-width:0;flex:1;}
+.cr-drawer-name {font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.cr-drawer-sub {font-size:11px;color:var(--bctext-2,#9aa3ab);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.cr-drawer-chevron {flex:none;color:var(--bctext-2,#9aa3ab);font-size:16px;}
+.cr-drawer-index,.cr-drawer-len {flex:none;font-size:11px;color:var(--bctext-2,#9aa3ab);font-variant-numeric:tabular-nums;}
+.cr-drawer-index {width:16px;text-align:right;}
+/* Daily Quote: looks only for now */
+.canvasrefined-quotes {background:linear-gradient(150deg, color-mix(in srgb, #b98cff 14%, var(--bcbackground-1,#fff)), var(--bcbackground-1,#fff));}
+.cr-quote-body {padding:14px 16px 16px;display:flex;flex-direction:column;gap:6px;flex:1;justify-content:center;}
+.cr-quote-mark {font-size:24px;line-height:.5;font-weight:800;color:color-mix(in srgb, var(--bclinks,#0374b5) 55%, transparent);}
+.cr-quote-text {margin:0;font-size:15px;line-height:1.5;font-style:italic;color:var(--bctext-0,#2d3b45);}
+.cr-quote-author {font-size:12px;font-weight:600;color:var(--bctext-2,#9aa3ab);align-self:flex-end;}
diff --git a/html/popup.html b/html/popup.html
index 7c68f0c..22316c4 100644
--- a/html/popup.html
+++ b/html/popup.html
@@ -1187,6 +1187,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/background.js b/js/background.js
index bd64175..988f034 100644
--- a/js/background.js
+++ b/js/background.js
@@ -42,6 +42,25 @@ chrome.runtime.onInstalled.addListener(function () {
"dashboard_notes_mode": "edit",
"dashboard_notes_position": "top",
"widget_order": {},
+ "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 },
+ "music": false,
+ "music_position": "cards_end",
+ "music_spotify_url": "",
+ "music_apple_url": "",
+ "music_youtube_url": "",
+ "music_thumb_bg": true,
+ "music_no_thumb": false,
+ "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",
"better_todo": true,
"todo_hr24": false,
"todo_separate_scrollbar": false,
diff --git a/js/content.js b/js/content.js
index 55e735a..0cf44a1 100644
--- a/js/content.js
+++ b/js/content.js
@@ -1026,6 +1026,33 @@ function applyOptionsChanges(changes) {
case "dashboard_notes_position":
loadDashboardNotes();
break;
+ case "focus_timer":
+ case "focus_timer_position":
+ case "focus_timer_preset_1":
+ case "focus_timer_preset_2":
+ case "focus_timer_preset_3":
+ loadFocusTimer();
+ break;
+ case "music":
+ case "music_position":
+ case "music_spotify_url":
+ case "music_apple_url":
+ case "music_youtube_url":
+ case "music_thumb_bg":
+ case "music_no_thumb":
+ loadMusicWidget();
+ break;
+ case "breathing":
+ case "breathing_position":
+ case "breathing_preset_1":
+ case "breathing_preset_2":
+ case "breathing_preset_3":
+ loadBreathingWidget();
+ break;
+ case "daily_quotes":
+ case "daily_quotes_position":
+ loadDailyQuotesWidget();
+ break;
case "widget_order":
["top", "below"].forEach(area => {
const row = document.querySelector(".canvasrefined-widget-row[data-area='" + area + "']");
@@ -1038,6 +1065,7 @@ function applyOptionsChanges(changes) {
// behind would keep the old layout until reload.
if (options.dashboard_notes === true) loadDashboardNotes();
if (options.gpa_calc === true && current_page === "/") setupGPACalc();
+ loadAllSimpleWidgets();
break;
case "dashboard_grades":
case "grade_hover":
@@ -1917,6 +1945,7 @@ function checkDashboardReady() {
insertGrades();
loadDashboardNotes();
setupGPACalc();
+ loadAllSimpleWidgets();
showUpdateMsg();
createNasaInfoOverlay();
}
@@ -7856,6 +7885,14 @@ function placeGPAElements(dashboardContainer, container, container2) {
}
}
+// Every dashboard widget known to the placement system. Kept in one place so
+// the ordering/filtering helpers don't each hardcode their own list.
+const CR_WIDGET_IDS = ["dashboard_notes", "gpa_calc", "focus_timer", "music", "breathing", "daily_quotes"];
+// Widgets rendered by the generic card loader (single root element, no GPA-style
+// paired elements).
+const CR_SIMPLE_WIDGET_IDS = ["focus_timer", "music", "breathing", "daily_quotes"];
+function isCRWidget(id) { return CR_WIDGET_IDS.indexOf(id) !== -1; }
+
// Shared full-width container ("top"/"below" positions): widgets live in a
// flex row; each widget's slot width is the % chosen in the Widgets tab.
function containerWidgetOrder(area) {
@@ -7868,7 +7905,7 @@ function containerWidgetOrder(area) {
return { id: e.id, size: Number.isFinite(n) && n > 0 ? n : null };
}
return null;
- }).filter(e => e && (e.id === "dashboard_notes" || e.id === "gpa_calc"));
+ }).filter(e => e && isCRWidget(e.id));
}
// Card-grid order ("Before cards"/"After cards" positions): saved order of
@@ -7881,7 +7918,7 @@ function cardWidgetOrder() {
if (typeof e === "string") return e;
if (e && typeof e === "object" && e.id) return e.id;
return null;
- }).filter(id => id === "dashboard_notes" || id === "gpa_calc");
+ }).filter(id => isCRWidget(id));
}
// Normalize the relative order of card-shaped widgets inside the card grid.
@@ -7893,6 +7930,14 @@ function applyCardWidgetOrder(grid) {
if (!grid) return;
const saved = cardWidgetOrder();
const units = [];
+ // Simple card widgets (focus timer, music, breathing, quotes) are a
+ // single element each, so they join the ordering straight from the DOM.
+ grid.querySelectorAll(":scope > .canvasrefined-widget-card[data-cr-widget]").forEach(el => {
+ if (!el.classList.contains("in-cards")) return;
+ const id = el.dataset.crWidget;
+ if (CR_SIMPLE_WIDGET_IDS.indexOf(id) === -1) return;
+ units.push({ id, anchor: (options[id + "_position"] === "cards_start") ? "start" : "end", els: [el] });
+ });
const notes = grid.querySelector(":scope > .canvasrefined-dashboard-notes");
if (notes && notes.classList.contains("in-cards")) {
units.push({
@@ -8635,6 +8680,569 @@ function loadDashboardNotes() {
}
}
+/*
+Generic dashboard widgets (Focus Timer, Music, Breathing Breaks, Daily Quote)
+
+These share the placement/ordering machinery built for Dashboard Notes and the
+GPA calculator, but render from one element each (see CR_SIMPLE_WIDGET_IDS).
+*/
+
+const CR_WIDGET_DEFAULT_POSITION = {
+ dashboard_notes: "top",
+ gpa_calc: "cards_end",
+ focus_timer: "cards_end",
+ music: "cards_end",
+ breathing: "cards_end",
+ daily_quotes: "top",
+};
+
+function crWidgetPosition(id) {
+ const v = options[id + "_position"];
+ if (v === "top" || v === "below" || v === "cards_start" || v === "cards_end") return v;
+ return CR_WIDGET_DEFAULT_POSITION[id] || "cards_end";
+}
+
+// Move a single-element widget to its configured spot, mirroring the Notes/GPA
+// placement rules: top/below join the shared full-width row, cards_* sit in the
+// course-card grid before/after the cards.
+function placeSimpleWidget(id, el, container) {
+ const pos = crWidgetPosition(id);
+ if (pos === "top" || pos === "below") {
+ el.classList.remove("in-cards");
+ el.classList.add("container-mode");
+ const slot = el.closest && el.closest(".canvasrefined-widget-slot");
+ const row = slot && slot.parentNode;
+ if (el.dataset.crPos === pos && row && row.dataset.area === pos && slot.dataset.widget === id) return;
+ placeWidgetInRow(id, pos, [el], container);
+ } else {
+ el.classList.add("in-cards");
+ el.classList.remove("container-mode");
+ const grid = document.querySelector(".ic-DashboardCard__box__container") || container;
+ if (el.dataset.crPos === pos && el.parentNode === grid) return;
+ releaseWidgetFromRow(id, [el], container);
+ if (pos === "cards_start") grid.prepend(el);
+ else grid.appendChild(el);
+ applyCardWidgetOrder(grid);
+ try {
+ if (grid !== container && window.jQuery && window.jQuery.fn && window.jQuery.fn.sortable) {
+ window.jQuery(grid).sortable('refresh');
+ }
+ } catch (e) {}
+ }
+ el.dataset.crPos = pos;
+}
+
+// Create (once), show/hide and place one generic widget from its spec.
+function crLoadWidget(id, spec) {
+ const existing = document.querySelector("." + spec.className);
+ if (options[id] !== true) {
+ if (existing) existing.style.display = "none";
+ return;
+ }
+ const container = document.querySelector("#DashboardCard_Container");
+ if (!container) return;
+ let el = existing;
+ if (!el) {
+ el = document.createElement("div");
+ el.className = "canvasrefined-widget-card " + spec.className;
+ el.dataset.crWidget = id;
+ el.innerHTML = spec.html;
+ try { if (spec.wire) spec.wire(el); } catch (e) { logError(e); }
+ } else {
+ el.style.display = "";
+ try { if (spec.update) spec.update(el); } catch (e) { logError(e); }
+ }
+ placeSimpleWidget(id, el, container);
+}
+
+/* ---- Shared timer engine (Focus Timer + Breathing Breaks) ---- */
+
+const crTimers = {};
+function crTimer(id) {
+ if (!crTimers[id]) crTimers[id] = { total: 0, remaining: 0, running: false, raf: null, endTime: 0 };
+ return crTimers[id];
+}
+// Countdown readout: ceil so a fresh 25:00 only shows 24:59 after a full second.
+function crTimerFormat(sec) {
+ const s = Math.max(0, Math.ceil(sec - 0.001));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const r = s % 60;
+ if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(r).padStart(2, "0");
+ return String(m).padStart(2, "0") + ":" + String(r).padStart(2, "0");
+}
+// Human label for a preset button ("15 min", "1h 30m", "45s").
+function crFormatDuration(sec) {
+ const s = Math.max(0, Math.round(sec));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const r = s % 60;
+ if (h && m) return h + "h " + m + "m";
+ if (h) return h + "h" + (r ? " " + r + "s" : "");
+ if (m && r) return m + "m " + r + "s";
+ if (m) return m + " min";
+ return r + "s";
+}
+function crTimerCancel(st) {
+ if (st && st.raf) { cancelAnimationFrame(st.raf); st.raf = null; }
+}
+function crTimerRender(id, el) {
+ const st = crTimer(id);
+ const disp = el.querySelector(".cr-timer-time") || el.querySelector(".cr-timer-display");
+ if (disp) {
+ const txt = crTimerFormat(st.remaining);
+ if (disp.textContent !== txt) disp.textContent = txt;
+ }
+ const start = el.querySelector(".cr-timer-start");
+ if (start) {
+ const label = st.running ? "Pause" : "Start";
+ if (start.textContent !== label) start.textContent = label;
+ }
+ el.classList.toggle("is-running", st.running);
+ const bar = el.querySelector(".cr-timer-progress-bar");
+ if (bar) bar.style.width = (st.total ? ((st.total - st.remaining) / st.total) * 100 : 0) + "%";
+ const presets = [...el.querySelectorAll(".cr-timer-preset[data-slot]")];
+ const totals = presets.map(b => parseInt(b.dataset.sec, 10));
+ const matched = totals.indexOf(st.total) !== -1;
+ presets.forEach((btn, i) => btn.classList.toggle("active", !st.running && totals[i] === st.total));
+ const customBtn = el.querySelector(".cr-timer-custom");
+ if (customBtn) customBtn.classList.toggle("active", !st.running && !matched && st.total > 0);
+}
+// rAF loop: the bar (and the readout) are driven by real elapsed time, so the
+// fill moves continuously instead of stepping once a second.
+function crTimerFrame(id, el) {
+ const st = crTimer(id);
+ if (!st.running) return;
+ st.remaining = Math.max(0, (st.endTime - Date.now()) / 1000);
+ if (st.remaining <= 0) {
+ st.remaining = 0;
+ st.running = false;
+ st.raf = null;
+ }
+ crTimerRender(id, el);
+ if (st.running) st.raf = requestAnimationFrame(() => crTimerFrame(id, el));
+}
+function crTimerStart(id, el) {
+ const st = crTimer(id);
+ if (st.running || st.remaining <= 0) return;
+ st.running = true;
+ st.endTime = Date.now() + st.remaining * 1000;
+ crTimerCancel(st);
+ st.raf = requestAnimationFrame(() => crTimerFrame(id, el));
+ crTimerRender(id, el);
+}
+function crTimerPause(id, el) {
+ const st = crTimer(id);
+ if (st.running) st.remaining = Math.max(0, (st.endTime - Date.now()) / 1000);
+ st.running = false;
+ crTimerCancel(st);
+ crTimerRender(id, el);
+}
+function crTimerSet(id, el, seconds) {
+ const st = crTimer(id);
+ st.running = false;
+ crTimerCancel(st);
+ st.total = Math.max(1, Math.round(seconds));
+ st.remaining = st.total;
+ crTimerRender(id, el);
+}
+function crTimerReset(id, el) {
+ const st = crTimer(id);
+ st.running = false;
+ crTimerCancel(st);
+ st.remaining = st.total;
+ crTimerRender(id, el);
+}
+// Only the three preset slots are configurable; Custom is typed on the widget.
+function crTimerApplyCfg(el, cfg) {
+ el.querySelectorAll(".cr-timer-preset[data-slot]").forEach(btn => {
+ const slot = parseInt(btn.dataset.slot, 10);
+ const sec = cfg.presets[slot - 1];
+ btn.dataset.sec = sec;
+ btn.textContent = crFormatDuration(sec);
+ });
+}
+function crTimerHideCustom(el) {
+ const row = el.querySelector(".cr-timer-custom-row");
+ if (row) row.hidden = true;
+}
+// Read the h/m/s custom fields as a whole number of seconds.
+function crTimerCustomSeconds(el) {
+ const num = (sel) => {
+ const v = parseInt((el.querySelector(sel) || {}).value, 10);
+ return Number.isFinite(v) && v > 0 ? v : 0;
+ };
+ const h = Math.min(99, num(".cr-tf-h"));
+ const m = Math.min(59, num(".cr-tf-m"));
+ const s = Math.min(59, num(".cr-tf-s"));
+ return h * 3600 + m * 60 + s;
+}
+// Listeners are wired once (update passes only refresh config labels).
+function crTimerWireControls(id, el) {
+ el.querySelectorAll(".cr-timer-preset[data-slot]").forEach(btn => {
+ btn.addEventListener("click", () => {
+ const sec = parseInt(btn.dataset.sec, 10);
+ if (Number.isFinite(sec) && sec > 0) crTimerSet(id, el, sec);
+ crTimerHideCustom(el);
+ });
+ });
+
+ // Custom: reveal inline hours/minutes/seconds fields.
+ const customBtn = el.querySelector(".cr-timer-custom");
+ const row = el.querySelector(".cr-timer-custom-row");
+ const applyCustom = () => {
+ const sec = crTimerCustomSeconds(el);
+ if (sec <= 0) {
+ const mInput = el.querySelector(".cr-tf-m");
+ if (mInput) { mInput.focus(); mInput.select(); }
+ return;
+ }
+ crTimerSet(id, el, sec);
+ crTimerHideCustom(el);
+ };
+ if (customBtn && row) customBtn.addEventListener("click", () => {
+ if (!row.hidden) { crTimerHideCustom(el); return; }
+ row.hidden = false;
+ const mInput = el.querySelector(".cr-tf-m");
+ if (mInput) { mInput.focus(); mInput.select(); }
+ });
+ el.querySelectorAll(".cr-timer-custom-row .cr-tf").forEach(inp => inp.addEventListener("keydown", e => {
+ if (e.key === "Enter") { e.preventDefault(); applyCustom(); }
+ else if (e.key === "Escape") { e.preventDefault(); crTimerHideCustom(el); }
+ }));
+ const go = el.querySelector(".cr-timer-custom-go");
+ if (go) go.addEventListener("click", applyCustom);
+
+ const start = el.querySelector(".cr-timer-start");
+ if (start) start.addEventListener("click", () => {
+ const st = crTimer(id);
+ if (st.running) crTimerPause(id, el);
+ else crTimerStart(id, el);
+ });
+ const reset = el.querySelector(".cr-timer-reset");
+ if (reset) reset.addEventListener("click", () => crTimerReset(id, el));
+}
+function crTimerInitial(id, el, cfg) {
+ const st = crTimer(id);
+ if (!st.total) { st.total = cfg.presets[1]; st.remaining = st.total; }
+ crTimerRender(id, el);
+}
+// Presets now store {h,m,s}; legacy values were plain minutes, so a number is
+// read as minutes. `fallback` is in seconds.
+function crTimerReadSlots(prefix, fallback) {
+ return [1, 2, 3].map(i => {
+ const v = options[prefix + "_preset_" + i];
+ if (v && typeof v === "object") {
+ const h = Math.max(0, parseInt(v.h, 10) || 0);
+ const m = Math.max(0, parseInt(v.m, 10) || 0);
+ const s = Math.max(0, parseInt(v.s, 10) || 0);
+ const total = h * 3600 + m * 60 + s;
+ if (total > 0) return total;
+ } else {
+ const n = parseInt(v, 10);
+ if (Number.isFinite(n) && n > 0) return n * 60;
+ }
+ return fallback[i - 1];
+ });
+}
+
+/* ---- Focus Timer ---- */
+
+const FOCUS_TIMER_DEFAULT_PRESETS = [15 * 60, 25 * 60, 45 * 60];
+
+function focusTimerCfg() {
+ return { presets: crTimerReadSlots("focus_timer", FOCUS_TIMER_DEFAULT_PRESETS) };
+}
+
+const FOCUS_TIMER_HTML = `
+ Focus Timer
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+function wireFocusTimer(el) {
+ crTimerApplyCfg(el, focusTimerCfg());
+ crTimerWireControls("focus_timer", el);
+ crTimerInitial("focus_timer", el, focusTimerCfg());
+}
+function updateFocusTimer(el) {
+ crTimerApplyCfg(el, focusTimerCfg());
+ crTimerRender("focus_timer", el);
+}
+function loadFocusTimer() {
+ crLoadWidget("focus_timer", {
+ className: "canvasrefined-focus-timer",
+ html: FOCUS_TIMER_HTML,
+ wire: wireFocusTimer,
+ update: updateFocusTimer,
+ });
+}
+
+/* ---- Breathing Breaks (a zen timer) ---- */
+
+const BREATHING_DEFAULT_PRESETS = [3 * 60, 5 * 60, 10 * 60];
+
+function breathingCfg() {
+ return { presets: crTimerReadSlots("breathing", BREATHING_DEFAULT_PRESETS) };
+}
+
+const BREATHING_HTML = `
+ Breathing Breaks
+
+
+ Breathe in
+ Breathe out
+ Ready
+
+
+ 05:00
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+function wireBreathingWidget(el) {
+ crTimerApplyCfg(el, breathingCfg());
+ crTimerWireControls("breathing", el);
+ crTimerInitial("breathing", el, breathingCfg());
+}
+function updateBreathingWidget(el) {
+ crTimerApplyCfg(el, breathingCfg());
+ crTimerRender("breathing", el);
+}
+function loadBreathingWidget() {
+ crLoadWidget("breathing", {
+ className: "canvasrefined-breathing",
+ html: BREATHING_HTML,
+ wire: wireBreathingWidget,
+ update: updateBreathingWidget,
+ });
+}
+
+/* ---- Music (looks only) ---- */
+
+const MUSIC_PLATFORMS = [
+ { id: "spotify", name: "Spotify", option: "music_spotify_url", color: "#1DB954" },
+ { id: "apple", name: "Apple Music", option: "music_apple_url", color: "#FA243C" },
+ { id: "youtube", name: "YouTube", option: "music_youtube_url", color: "#FF0000" },
+];
+
+const MUSIC_ICONS = {
+ spotify: '',
+ apple: '',
+ youtube: '',
+ note: '',
+ prev: '',
+ play: '',
+ next: '',
+};
+
+function musicPlatformLabel(url) {
+ try {
+ const host = new URL(url).hostname.replace(/^www\./, "");
+ return host || "Open playlist";
+ } catch (e) {
+ return "Open playlist";
+ }
+}
+
+// Sample library used by the switcher drawer until playback is wired up.
+const MUSIC_SAMPLE_PLAYLISTS = {
+ spotify: ["Focus Flow", "Lo-fi Study"],
+ apple: ["Morning Mix", "Reading Room"],
+ youtube: ["Study With Me", "Ambient Spaces"],
+};
+const MUSIC_SAMPLE_SONGS = [
+ { title: "Golden Hour", artist: "Ambient Co.", len: "3:12" },
+ { title: "Slow Tide", artist: "Nova", len: "4:05" },
+ { title: "Paper Planes", artist: "The Loft", len: "2:48" },
+ { title: "Night Drive", artist: "Kite", len: "3:57" },
+ { title: "Warm Static", artist: "Halcyon", len: "5:21" },
+];
+
+const MUSIC_HTML = `
+
+ Music
+
+
+
+
${MUSIC_ICONS.note}
+
+
${MUSIC_ICONS.note}
+
+
Nothing playing
+
Pick a playlist
+
+
+
+
+ 0:000:00
+
+
+
+
+
+
+
+ Library
+
+
+
+
+
+
+
+
+
+ ${MUSIC_PLATFORMS.map(p => `
+
+ ${MUSIC_ICONS[p.id]}
+ ${p.name}Not linked
+ ›
+
`).join("")}
+
+
+ ${MUSIC_PLATFORMS.map(p => `
+
+ ${MUSIC_ICONS[p.id]}
+ ${MUSIC_SAMPLE_PLAYLISTS[p.id][0]}${p.name}
+ ›
+
`).join("")}
+
+
+ ${MUSIC_SAMPLE_SONGS.map((s, i) => `
+
+ ${i + 1}
+ ${s.title}${s.artist}
+ ${s.len}
+
`).join("")}
+
+
+
+`;
+
+// No playback yet: this only keeps the look in sync with the saved links and
+// display options.
+function updateMusicWidget(el) {
+ const noThumb = options.music_no_thumb === true;
+ const thumbBg = !noThumb && options.music_thumb_bg !== false;
+ el.classList.toggle("no-thumb", noThumb);
+ el.classList.toggle("thumb-bg", thumbBg);
+
+ const configured = [];
+ MUSIC_PLATFORMS.forEach(p => {
+ const url = (options[p.option] || "").trim();
+ if (url) configured.push({ p, url });
+ const sub = el.querySelector('.cr-drawer-platform[data-platform="' + p.id + '"] [data-role="platform-sub"]');
+ if (sub) sub.textContent = url ? musicPlatformLabel(url) : "Not linked";
+ const plName = el.querySelector('.cr-drawer-playlist[data-platform="' + p.id + '"] [data-role="playlist-name"]');
+ if (plName) plName.textContent = url ? musicPlatformLabel(url) : MUSIC_SAMPLE_PLAYLISTS[p.id][0];
+ });
+
+ // Mirror the first configured playlist into the player header.
+ const titleEl = el.querySelector(".cr-player-title");
+ const subEl = el.querySelector(".cr-player-sub");
+ const artEl = el.querySelector(".cr-player-art");
+ const first = configured[0];
+ if (first) {
+ if (titleEl) titleEl.textContent = first.p.name + " playlist";
+ if (subEl) subEl.textContent = musicPlatformLabel(first.url);
+ if (artEl) artEl.innerHTML = MUSIC_ICONS[first.p.id];
+ el.style.setProperty("--cr-thumb-color", first.p.color);
+ } else {
+ if (titleEl) titleEl.textContent = "Nothing playing";
+ if (subEl) subEl.textContent = "Pick a playlist";
+ if (artEl) artEl.innerHTML = MUSIC_ICONS.note;
+ el.style.removeProperty("--cr-thumb-color");
+ }
+}
+function wireMusicWidget(el) {
+ const btn = el.querySelector(".cr-music-list-btn");
+ const drawer = el.querySelector(".cr-music-drawer");
+ const setOpen = (v) => {
+ el.classList.toggle("is-drawer-open", v);
+ if (drawer) drawer.setAttribute("aria-hidden", v ? "false" : "true");
+ };
+ if (btn && drawer) btn.addEventListener("click", () => setOpen(!el.classList.contains("is-drawer-open")));
+ const close = el.querySelector(".cr-drawer-close");
+ if (close) close.addEventListener("click", () => setOpen(false));
+ el.querySelectorAll(".cr-drawer-tab").forEach(tab => {
+ tab.addEventListener("click", () => {
+ const name = tab.dataset.tab;
+ el.querySelectorAll(".cr-drawer-tab").forEach(t => t.classList.toggle("active", t === tab));
+ el.querySelectorAll(".cr-drawer-pane").forEach(p => { p.hidden = p.dataset.pane !== name; });
+ });
+ });
+ updateMusicWidget(el);
+}
+function loadMusicWidget() {
+ crLoadWidget("music", {
+ className: "canvasrefined-music",
+ html: MUSIC_HTML,
+ wire: wireMusicWidget,
+ update: updateMusicWidget,
+ });
+}
+
+/* ---- Daily Quote (looks only, content comes later) ---- */
+
+const QUOTES_HTML = `
+ Daily Quote
+
+
“
+
The secret of getting ahead is getting started.
+
— Mark Twain
+
+`;
+function loadDailyQuotesWidget() {
+ crLoadWidget("daily_quotes", {
+ className: "canvasrefined-quotes",
+ html: QUOTES_HTML,
+ });
+}
+
+function loadAllSimpleWidgets() {
+ loadFocusTimer();
+ loadMusicWidget();
+ loadBreathingWidget();
+ loadDailyQuotesWidget();
+}
+
/*
Custom font
diff --git a/js/popup.js b/js/popup.js
index 6b5b76c..a102bb1 100644
--- a/js/popup.js
+++ b/js/popup.js
@@ -1,4 +1,4 @@
-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', '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 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', 'music', '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",
@@ -20,6 +20,21 @@ const syncedSubOptions = [
"gpa_calc_prepend",
"gpa_calc_cumulative",
"gpa_calc_weighted",
+ "focus_timer_position",
+ "focus_timer_preset_1",
+ "focus_timer_preset_2",
+ "focus_timer_preset_3",
+ "music_position",
+ "music_spotify_url",
+ "music_apple_url",
+ "music_youtube_url",
+ "music_thumb_bg",
+ "music_no_thumb",
+ "breathing_position",
+ "breathing_preset_1",
+ "breathing_preset_2",
+ "breathing_preset_3",
+ "daily_quotes_position",
"auto_dark",
"auto_dark_start",
"auto_dark_end",
@@ -130,6 +145,25 @@ const defaultOptions = {
"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 },
+ "music": false,
+ "music_position": "cards_end",
+ "music_spotify_url": "",
+ "music_apple_url": "",
+ "music_youtube_url": "",
+ "music_thumb_bg": true,
+ "music_no_thumb": false,
+ "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",
"better_todo": true,
"better_sidebar": false,
"sidebar_scale": 100,
@@ -1007,6 +1041,10 @@ function toggleAlternateColorsVisibility(darkModeOn) {
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: "music", nameKey: "music", shortKey: "widget_short_music" },
+ { id: "breathing", nameKey: "breathing_breaks", shortKey: "widget_short_breathing" },
+ { id: "daily_quotes", nameKey: "daily_quotes", shortKey: "widget_short_quotes" },
];
function widgetPosFromSync(id, sync) {
@@ -1015,7 +1053,7 @@ function widgetPosFromSync(id, sync) {
if (sync.gpa_calc_position !== undefined) return sync.gpa_calc_position;
return sync.gpa_calc_prepend === true ? "cards_start" : "cards_end";
}
- return "top";
+ return sync[id + "_position"] || (defaultOptions.sync[id + "_position"]) || "top";
}
const WIDGET_ORDER_MIN_PCT = 15;
@@ -1037,7 +1075,11 @@ function parseWidgetOrderEntries(raw) {
function widgetDisplayName(id) {
const info = WIDGET_LIST.find(w => w.id === id);
- return (info && chrome.i18n.getMessage(info.shortKey || info.nameKey)) || 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
@@ -1050,7 +1092,9 @@ function setupWidgetOrder() {
const wrap = document.getElementById("widget-order-container");
if (!wrap) return;
- const KEYS = ["dashboard_notes", "gpa_calc", "dashboard_notes_position", "gpa_calc_position", "gpa_calc_prepend", "widget_order"];
+ 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"];
@@ -1223,6 +1267,10 @@ 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: "music_position", key: "music_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;
@@ -1241,10 +1289,48 @@ function setupWidgetPosition() {
});
}
+// Hours/minutes/seconds widgets settings (Focus Timer / Breathing presets).
+function setupWidgetTimeInput(prefix, initial) {
+ const hEl = document.getElementById(prefix + "_h");
+ const mEl = document.getElementById(prefix + "_m");
+ const sEl = document.getElementById(prefix + "_s");
+ if (!hEl || !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 });
+ }
+ hEl.value = v.h || 0;
+ mEl.value = v.m || 0;
+ sEl.value = v.s || 0;
+ const save = () => {
+ const clamp = (el, max) => Math.min(max, Math.max(0, parseInt(el.value, 10) || 0));
+ let h = clamp(hEl, 99), m = clamp(mEl, 59), s = clamp(sEl, 59);
+ if (h === 0 && m === 0 && s === 0) {
+ const d = defaultOptions.sync[prefix] || { h: 0, m: 5, s: 0 };
+ h = d.h; m = d.m; s = d.s;
+ }
+ hEl.value = h; mEl.value = m; sEl.value = s;
+ chrome.storage.sync.set({ [prefix]: { h, m, s } });
+ };
+ [hEl, mEl, sEl].forEach(el => el.addEventListener("change", save));
+}
+
+// Text/URL widget settings (Music playlist links).
+function setupWidgetTextInput(id, initial) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.value = initial || "";
+ el.addEventListener("change", function () {
+ chrome.storage.sync.set({ [id]: this.value.trim() });
+ });
+}
+
// 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", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"];
+ const togglesWithSubOptions = ["dashboard_notes", "gpa_calc", "focus_timer", "music", "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;
@@ -1649,6 +1735,8 @@ function setup() {
"customBackgroundNasaDaily",
"fitImageToScreen",
"card_transparency",
+ "music_thumb_bg",
+ "music_no_thumb",
"customCardStyles",
"card_grid",
"card_grid_center_rows",
@@ -1843,6 +1931,42 @@ function setup() {
identifier: "anon_progress_total",
setup: (initial) => setupAnonProgressInput("anon_progress_total", initial, false),
},
+ {
+ 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: "music_spotify_url",
+ setup: (initial) => setupWidgetTextInput("music_spotify_url", initial),
+ },
+ {
+ identifier: "music_apple_url",
+ setup: (initial) => setupWidgetTextInput("music_apple_url", initial),
+ },
+ {
+ identifier: "music_youtube_url",
+ setup: (initial) => setupWidgetTextInput("music_youtube_url", initial),
+ },
{
identifier: "todo_more_expanded",
setup: (initial) => setupTodoMoreOptions(initial),
@@ -1888,7 +2012,7 @@ function setup() {
chrome.storage.sync.set({ "hide_course_images": false });
}
toggleSubOptionsVisibility(option, status);
- if (option === "dashboard_notes" || option === "gpa_calc") {
+ if (option === "dashboard_notes" || option === "gpa_calc" || option === "focus_timer" || option === "music" || option === "breathing" || option === "daily_quotes") {
if (refreshWidgetOrder) refreshWidgetOrder();
}
});
@@ -1898,7 +2022,7 @@ function setup() {
chrome.storage.sync.get("todo_celebrations_enabled", v => {
toggleCelebrationOptionsVisibility(v["todo_celebrations_enabled"] !== false);
});
- ["dashboard_notes", "gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => {
+ ["dashboard_notes", "gpa_calc", "focus_timer", "music", "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);