base for more widgets

This commit is contained in:
Guy Sandler 2026-10-16 20:36:30 -07:00
parent 1ee82a68b0
commit b05f9b58c8
6 changed files with 1079 additions and 10 deletions

View File

@ -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"
},

View File

@ -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;}

View File

@ -1187,6 +1187,158 @@
</div>
</div>
</div>
<div class="option-container">
<div class="option" id="focus_timer">
<input type="radio" id="off" name="focus_timer">
<input type="radio" id="on" name="focus_timer">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div><span class="option-name" data-i18n="focus_timer">Focus Timer</span>
</div>
<div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;">
<label for="focus_timer_position" class="sub-text" data-i18n="widget_position">Position</label>
<select id="focus_timer_position" class="widget-pos">
<option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option>
</select>
</div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;">
<span class="sub-text">Button 1</span>
<div style="display:flex;gap:5px;align-items:center;">
<input type="number" id="focus_timer_preset_1_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span>
<input type="number" id="focus_timer_preset_1_m" class="card-input" min="0" max="59" value="15" style="width:46px;margin-top:0;"><span class="sub-text">m</span>
<input type="number" id="focus_timer_preset_1_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span>
</div>
</div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;">
<span class="sub-text">Button 2</span>
<div style="display:flex;gap:5px;align-items:center;">
<input type="number" id="focus_timer_preset_2_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span>
<input type="number" id="focus_timer_preset_2_m" class="card-input" min="0" max="59" value="25" style="width:46px;margin-top:0;"><span class="sub-text">m</span>
<input type="number" id="focus_timer_preset_2_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span>
</div>
</div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;">
<span class="sub-text">Button 3</span>
<div style="display:flex;gap:5px;align-items:center;">
<input type="number" id="focus_timer_preset_3_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span>
<input type="number" id="focus_timer_preset_3_m" class="card-input" min="0" max="59" value="45" style="width:46px;margin-top:0;"><span class="sub-text">m</span>
<input type="number" id="focus_timer_preset_3_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span>
</div>
</div>
</div>
</div>
<div class="option-container">
<div class="option" id="breathing">
<input type="radio" id="off" name="breathing">
<input type="radio" id="on" name="breathing">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div><span class="option-name" data-i18n="breathing_breaks">Breathing Breaks</span>
</div>
<div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;">
<label for="breathing_position" class="sub-text" data-i18n="widget_position">Position</label>
<select id="breathing_position" class="widget-pos">
<option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option>
</select>
</div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;">
<span class="sub-text">Button 1</span>
<div style="display:flex;gap:5px;align-items:center;">
<input type="number" id="breathing_preset_1_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span>
<input type="number" id="breathing_preset_1_m" class="card-input" min="0" max="59" value="3" style="width:46px;margin-top:0;"><span class="sub-text">m</span>
<input type="number" id="breathing_preset_1_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span>
</div>
</div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;">
<span class="sub-text">Button 2</span>
<div style="display:flex;gap:5px;align-items:center;">
<input type="number" id="breathing_preset_2_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span>
<input type="number" id="breathing_preset_2_m" class="card-input" min="0" max="59" value="5" style="width:46px;margin-top:0;"><span class="sub-text">m</span>
<input type="number" id="breathing_preset_2_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span>
</div>
</div>
<div class="sub-option" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:3px;">
<span class="sub-text">Button 3</span>
<div style="display:flex;gap:5px;align-items:center;">
<input type="number" id="breathing_preset_3_h" class="card-input" min="0" max="99" value="0" style="width:46px;margin-top:0;"><span class="sub-text">h</span>
<input type="number" id="breathing_preset_3_m" class="card-input" min="0" max="59" value="10" style="width:46px;margin-top:0;"><span class="sub-text">m</span>
<input type="number" id="breathing_preset_3_s" class="card-input" min="0" max="59" value="0" style="width:46px;margin-top:0;"><span class="sub-text">s</span>
</div>
</div>
</div>
</div>
<div class="option-container">
<div class="option" id="music">
<input type="radio" id="off" name="music">
<input type="radio" id="on" name="music">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div><span class="option-name" data-i18n="music">Music</span>
</div>
<div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;">
<label for="music_position" class="sub-text" data-i18n="widget_position">Position</label>
<select id="music_position" class="widget-pos">
<option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option>
</select>
</div>
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:6px;">
<label for="music_spotify_url" class="sub-text">Spotify playlist</label>
<input type="text" id="music_spotify_url" class="card-input" placeholder="https://open.spotify.com/playlist/...">
</div>
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:6px;">
<label for="music_apple_url" class="sub-text">Apple Music playlist</label>
<input type="text" id="music_apple_url" class="card-input" placeholder="https://music.apple.com/...">
</div>
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:6px;">
<label for="music_youtube_url" class="sub-text">YouTube playlist</label>
<input type="text" id="music_youtube_url" class="card-input" placeholder="https://youtube.com/playlist?...">
</div>
<div class="sub-option" style="margin-top:9px">
<input type="checkbox" id="music_thumb_bg" name="music_thumb_bg">
<label for="music_thumb_bg" class="sub-text">Thumbnail background</label>
</div>
<div class="sub-option" style="margin-top:5px">
<input type="checkbox" id="music_no_thumb" name="music_no_thumb">
<label for="music_no_thumb" class="sub-text">Remove thumbnail</label>
</div>
</div>
</div>
<div class="option-container">
<div class="option" id="daily_quotes">
<input type="radio" id="off" name="daily_quotes">
<input type="radio" id="on" name="daily_quotes">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div><span class="option-name" data-i18n="daily_quotes">Daily Quotes</span>
</div>
<div class="sub-options">
<div class="sub-option" style="flex-direction:column;align-items:stretch;gap:4px;">
<label for="daily_quotes_position" class="sub-text" data-i18n="widget_position">Position</label>
<select id="daily_quotes_position" class="widget-pos">
<option value="top" data-i18n="pos_top">Top</option>
<option value="cards_start" data-i18n="pos_cards_start">Before cards</option>
<option value="cards_end" data-i18n="pos_cards_end">After cards</option>
<option value="below" data-i18n="pos_below">Bottom</option>
</select>
</div>
</div>
</div>
</div>
</div>

View File

@ -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,

View File

@ -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 = `
<div class="cr-widget-head"><span class="cr-widget-title">Focus Timer</span></div>
<div class="cr-time-box cr-focus-display">
<div class="cr-time-fill cr-timer-progress-bar"></div>
<div class="cr-timer-time cr-timer-display" aria-live="polite">25:00</div>
</div>
<div class="cr-timer-presets">
<button type="button" class="cr-timer-preset" data-slot="1">15 min</button>
<button type="button" class="cr-timer-preset" data-slot="2">25 min</button>
<button type="button" class="cr-timer-preset" data-slot="3">45 min</button>
<button type="button" class="cr-timer-preset cr-timer-custom">Custom</button>
</div>
<div class="cr-timer-custom-row" hidden>
<div class="cr-time-fields">
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-h" min="0" max="99" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">h</span></label>
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-m" min="0" max="59" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">m</span></label>
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-s" min="0" max="59" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">s</span></label>
</div>
<button type="button" class="cr-widget-btn cr-timer-custom-go">Set</button>
</div>
<div class="cr-timer-controls">
<button type="button" class="cr-widget-btn cr-timer-start">Start</button>
<button type="button" class="cr-widget-btn cr-widget-btn-ghost cr-timer-reset">Reset</button>
</div>
`;
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 = `
<div class="cr-widget-head"><span class="cr-widget-title">Breathing Breaks</span></div>
<div class="cr-breath-stage">
<div class="cr-breath-circle">
<span class="cr-breath-phase cr-breath-phase-in">Breathe in</span>
<span class="cr-breath-phase cr-breath-phase-out">Breathe out</span>
<span class="cr-breath-idle">Ready</span>
</div>
</div>
<div class="cr-timer-time cr-timer-display cr-breath-display" aria-live="polite">05:00</div>
<div class="cr-timer-presets">
<button type="button" class="cr-timer-preset" data-slot="1">3 min</button>
<button type="button" class="cr-timer-preset" data-slot="2">5 min</button>
<button type="button" class="cr-timer-preset" data-slot="3">10 min</button>
<button type="button" class="cr-timer-preset cr-timer-custom">Custom</button>
</div>
<div class="cr-timer-custom-row" hidden>
<div class="cr-time-fields">
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-h" min="0" max="99" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">h</span></label>
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-m" min="0" max="59" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">m</span></label>
<label class="cr-tf-box"><input type="number" class="cr-tf cr-tf-s" min="0" max="59" inputmode="numeric" placeholder="0"><span class="cr-tf-unit">s</span></label>
</div>
<button type="button" class="cr-widget-btn cr-timer-custom-go">Set</button>
</div>
<div class="cr-timer-controls">
<button type="button" class="cr-widget-btn cr-timer-start">Start</button>
<button type="button" class="cr-widget-btn cr-widget-btn-ghost cr-timer-reset">Reset</button>
</div>
`;
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: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="#1DB954"/><path d="M7 9.6c3.3-1 7-.6 9.8 1M7.4 12.6c2.6-.8 5.4-.4 7.6 .9M8 15.5c2-.6 4-.3 5.6 .7" stroke="#fff" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>',
apple: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="#FA243C"/><path d="M15.2 6.5l-5 1.1a.8.8 0 0 0-.6.8v5.2a2 2 0 1 0 .9 1.7V9.3l4-0.9v3.6a2 2 0 1 0 .9 1.7V7.3a.8.8 0 0 0-.2-.8z" fill="#fff"/></svg>',
youtube: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><rect x="1.5" y="5" width="21" height="14" rx="4" fill="#FF0000"/><path d="M10 9l5 3-5 3z" fill="#fff"/></svg>',
note: '<svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor" aria-hidden="true"><path d="M20 3v11.6a3.6 3.6 0 1 1-2-3.2V6.3l-8 1.6v8.7a3.6 3.6 0 1 1-2-3.2V5.4L20 3z"/></svg>',
prev: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M6 6h2v12H6zM20 6v12l-9-6z"/></svg>',
play: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>',
next: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16 6h2v12h-2zM4 6v12l9-6z"/></svg>',
};
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 = `
<div class="cr-widget-head">
<span class="cr-widget-title">Music</span>
<button type="button" class="cr-music-list-btn">Playlists <span class="cr-music-caret" aria-hidden="true">&#9662;</span></button>
</div>
<div class="cr-player">
<div class="cr-player-thumb" aria-hidden="true"><span class="cr-player-thumb-mark">${MUSIC_ICONS.note}</span></div>
<div class="cr-player-body">
<div class="cr-player-art" aria-hidden="true">${MUSIC_ICONS.note}</div>
<div class="cr-player-info">
<div class="cr-player-title">Nothing playing</div>
<div class="cr-player-sub">Pick a playlist</div>
</div>
</div>
</div>
<div class="cr-player-progress"><div class="cr-player-progress-fill"></div></div>
<div class="cr-player-times"><span>0:00</span><span>0:00</span></div>
<div class="cr-player-controls">
<button type="button" class="cr-player-btn" aria-label="Previous track">${MUSIC_ICONS.prev}</button>
<button type="button" class="cr-player-btn cr-player-play" aria-label="Play">${MUSIC_ICONS.play}</button>
<button type="button" class="cr-player-btn" aria-label="Next track">${MUSIC_ICONS.next}</button>
</div>
<div class="cr-music-drawer" aria-hidden="true">
<div class="cr-drawer-head">
<span class="cr-drawer-title">Library</span>
<button type="button" class="cr-drawer-close" aria-label="Close">&times;</button>
</div>
<div class="cr-drawer-tabs">
<button type="button" class="cr-drawer-tab active" data-tab="platforms">Platforms</button>
<button type="button" class="cr-drawer-tab" data-tab="playlists">Playlists</button>
<button type="button" class="cr-drawer-tab" data-tab="songs">Songs</button>
</div>
<div class="cr-drawer-body">
<div class="cr-drawer-pane" data-pane="platforms">
${MUSIC_PLATFORMS.map(p => `
<div class="cr-drawer-row cr-drawer-platform" data-platform="${p.id}">
<span class="cr-drawer-icon">${MUSIC_ICONS[p.id]}</span>
<span class="cr-drawer-meta"><span class="cr-drawer-name">${p.name}</span><span class="cr-drawer-sub" data-role="platform-sub">Not linked</span></span>
<span class="cr-drawer-chevron" aria-hidden="true">&#8250;</span>
</div>`).join("")}
</div>
<div class="cr-drawer-pane" data-pane="playlists" hidden>
${MUSIC_PLATFORMS.map(p => `
<div class="cr-drawer-row cr-drawer-playlist" data-platform="${p.id}">
<span class="cr-drawer-icon cr-drawer-icon-pl">${MUSIC_ICONS[p.id]}</span>
<span class="cr-drawer-meta"><span class="cr-drawer-name" data-role="playlist-name">${MUSIC_SAMPLE_PLAYLISTS[p.id][0]}</span><span class="cr-drawer-sub">${p.name}</span></span>
<span class="cr-drawer-chevron" aria-hidden="true">&#8250;</span>
</div>`).join("")}
</div>
<div class="cr-drawer-pane" data-pane="songs" hidden>
${MUSIC_SAMPLE_SONGS.map((s, i) => `
<div class="cr-drawer-row cr-drawer-song">
<span class="cr-drawer-index">${i + 1}</span>
<span class="cr-drawer-meta"><span class="cr-drawer-name">${s.title}</span><span class="cr-drawer-sub">${s.artist}</span></span>
<span class="cr-drawer-len">${s.len}</span>
</div>`).join("")}
</div>
</div>
</div>
`;
// 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 = `
<div class="cr-widget-head"><span class="cr-widget-title">Daily Quote</span></div>
<div class="cr-quote-body">
<span class="cr-quote-mark" aria-hidden="true">&#8220;</span>
<p class="cr-quote-text">The secret of getting ahead is getting started.</p>
<span class="cr-quote-author">&mdash; Mark Twain</span>
</div>
`;
function loadDailyQuotesWidget() {
crLoadWidget("daily_quotes", {
className: "canvasrefined-quotes",
html: QUOTES_HTML,
});
}
function loadAllSimpleWidgets() {
loadFocusTimer();
loadMusicWidget();
loadBreathingWidget();
loadDailyQuotesWidget();
}
/*
Custom font

View File

@ -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);