@ivanbard/dd2l 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -3
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/bin/dd2l.mjs +7 -0
- package/docs/README.md +227 -0
- package/package.json +56 -6
- package/src/app-runner.mjs +58 -0
- package/src/assignment-workspace.mjs +128 -0
- package/src/canvas.mjs +17 -0
- package/src/cli-banner.mjs +58 -0
- package/src/cli.mjs +142 -0
- package/src/client.mjs +150 -0
- package/src/connector.mjs +587 -0
- package/src/discovery.mjs +45 -0
- package/src/documents.mjs +154 -0
- package/src/links.mjs +99 -0
- package/src/llm.mjs +243 -0
- package/src/media.mjs +54 -0
- package/src/ocr.mjs +24 -0
- package/src/office.mjs +61 -0
- package/src/pdf-worker.mjs +26 -0
- package/src/research.mjs +70 -0
- package/src/session.mjs +142 -0
- package/src/store.mjs +108 -0
- package/src/syllabus.mjs +191 -0
- package/src/task-workspace.mjs +138 -0
- package/src/ui-data.mjs +123 -0
- package/src/ui-demo.mjs +36 -0
- package/src/ui-server.mjs +161 -0
- package/src/windows-ocr.ps1 +32 -0
- package/ui/answer-renderer.js +88 -0
- package/ui/answer-view.js +47 -0
- package/ui/app.js +385 -0
- package/ui/assets/LUCIDE-LICENSE.txt +15 -0
- package/ui/assets/avatar.svg +3 -0
- package/ui/assets/chevron.svg +5 -0
- package/ui/assets/courses.svg +14 -0
- package/ui/assets/logo.svg +13 -0
- package/ui/assets/new-chat.svg +14 -0
- package/ui/assets/search.svg +14 -0
- package/ui/assets/send.svg +8 -0
- package/ui/assets/settings.svg +14 -0
- package/ui/assets/widget-announcements.svg +4 -0
- package/ui/assets/widget-shortcuts.svg +5 -0
- package/ui/assignment-workspace.js +106 -0
- package/ui/canvas-contract.js +41 -0
- package/ui/canvas-runtime.js +158 -0
- package/ui/canvas.js +96 -0
- package/ui/index.html +84 -0
- package/ui/loading.js +33 -0
- package/ui/style.css +241 -0
- package/ui/widget-layout.js +39 -0
- package/ui/widgets.js +252 -0
package/ui/style.css
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
:root { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', 'Segoe UI', sans-serif; color: #fff; background: #222; color-scheme: dark; font-synthesis: none; }
|
|
2
|
+
* { box-sizing: border-box; }
|
|
3
|
+
body { margin: 0; min-width: 320px; }
|
|
4
|
+
button, input, select { font: inherit; }
|
|
5
|
+
button, a, select { -webkit-tap-highlight-color: transparent; }
|
|
6
|
+
button { cursor: pointer; }
|
|
7
|
+
button:disabled { cursor: default; }
|
|
8
|
+
button, select { color: inherit; }
|
|
9
|
+
button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, h2:focus-visible { outline: 2px solid #75c9e2; outline-offset: 4px; }
|
|
10
|
+
[hidden] { display: none !important; }
|
|
11
|
+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
12
|
+
.skip-link { position: fixed; top: -80px; left: 75px; z-index: 5; background: #333; color: white; padding: 12px; }
|
|
13
|
+
.skip-link:focus { top: 12px; }
|
|
14
|
+
.sidebar { position: fixed; inset: 0 auto 0 0; width: 60px; height: 100dvh; background: #1a1a1a; border-right: 1px solid #5e5e5e66; display: flex; flex-direction: column; align-items: center; z-index: 2; }
|
|
15
|
+
.brand { display: grid; place-items: center; width: 44px; height: 44px; margin-top: 12px; }
|
|
16
|
+
.brand img, .icon-button img { display: block; }
|
|
17
|
+
nav { margin-top: 20px; display: grid; gap: 8px; }
|
|
18
|
+
.icon-button { width: 44px; height: 44px; padding: 14px; border: 0; border-radius: 10px; background: transparent; transition: background .15s ease; }
|
|
19
|
+
.icon-button:hover, .icon-button[aria-current] { background: #ffffff0c; }
|
|
20
|
+
.utilities { margin-top: auto; display: flex; align-items: center; flex-direction: column; gap: 12px; padding-bottom: 9px; }
|
|
21
|
+
.profile { display: grid; place-items: center; position: relative; width: 40px; height: 40px; padding: 0; border: 0; background: transparent; border-radius: 50%; }
|
|
22
|
+
.profile img, .profile span { grid-area: 1 / 1; }
|
|
23
|
+
.profile span { font-size: 20px; }
|
|
24
|
+
.workspace { padding: 88px 48px 64px 108px; }
|
|
25
|
+
.home { width: min(560px, 100%); margin: 0 auto; }
|
|
26
|
+
h1 { font-size: 24px; line-height: 29px; font-weight: 400; text-align: center; margin: 0 0 6px; letter-spacing: -.4px; }
|
|
27
|
+
.composer { height: 128px; padding: 20px; border-radius: 42px; background: linear-gradient(137deg, #313131 -10%, #1a1a1a 86%); box-shadow: 5px 7px 13.5px 8px #0004; }
|
|
28
|
+
.prompt-field { height: 44px; border-radius: 22px; background: #2f2f2f; border: 1px solid #5e5e5e66; display: flex; align-items: center; padding: 5px 7px 5px 15px; transition: border-color .15s ease; }
|
|
29
|
+
.prompt-field:focus-within { border-color: #818181; }
|
|
30
|
+
input { min-width: 0; width: 100%; border: 0; outline: none; color: #fff; background: transparent; font-size: 16px; }
|
|
31
|
+
input:focus-visible { outline: none; }
|
|
32
|
+
input::-webkit-search-cancel-button { display: none; }
|
|
33
|
+
#send { display: grid; place-items: center; flex: 0 0 32px; height: 32px; border: 0; padding: 0; background: transparent; border-radius: 50%; transition: filter .15s ease, transform .15s ease; }
|
|
34
|
+
#send img { display: block; }
|
|
35
|
+
.composer[aria-busy=true] #send img { display: none; }
|
|
36
|
+
#send:not(:disabled) { filter: brightness(2); }
|
|
37
|
+
#send:not(:disabled):hover { transform: translateY(-1px); filter: brightness(2.6); }
|
|
38
|
+
.composer-footer { position: relative; display: flex; align-items: center; gap: 4px; margin-top: 12px; height: 32px; }
|
|
39
|
+
.composer-footer select { min-width: 0; max-width: 55%; }
|
|
40
|
+
.composer-footer #model { margin-left: auto; max-width: 45%; }
|
|
41
|
+
.loading-slot:empty { display: none; }
|
|
42
|
+
.loading-slot { display: inline-flex; vertical-align: middle; flex-shrink: 0; }
|
|
43
|
+
.dot-loader { display: inline-grid; grid-template-columns: repeat(5, 3px); grid-template-rows: repeat(5, 3px); gap: 2.25px; width: 24px; height: 24px; flex: 0 0 24px; vertical-align: middle; color: inherit; }
|
|
44
|
+
.dot-loader > span { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .24; animation: dot-origin-wave 1500ms ease-in-out infinite; animation-delay: calc(var(--ring) * 240ms); will-change: opacity; }
|
|
45
|
+
@keyframes dot-origin-wave { 0%, 100% { opacity: .1; } 34% { opacity: 1; } 60% { opacity: .24; } }
|
|
46
|
+
#panel-loading { margin-left: auto; margin-right: 12px; }
|
|
47
|
+
.account-action[aria-busy=true] { display: flex; align-items: center; gap: 12px; }
|
|
48
|
+
select { appearance: none; field-sizing: content; max-width: min(350px, 85%); padding: 8px 30px 8px 12px; border: 0; border-radius: 8px; font-size: 13px; line-height: 16px; background: transparent url('/assets/chevron.svg') right 10px center / 14px 14px no-repeat; color: #bababa; cursor: pointer; text-overflow: ellipsis; }
|
|
49
|
+
select:hover { background-color: #ffffff08; }
|
|
50
|
+
option { background: #292929; color: white; }
|
|
51
|
+
#busy { color: #bababa; font-size: 12px; }
|
|
52
|
+
.notice { color: #bababa; font-size: 13px; line-height: 1.6; margin: 24px 12px 0; text-align: center; }
|
|
53
|
+
.demo-badge { position: fixed; right: 20px; top: 18px; color: #aaa; font-size: 12px; }
|
|
54
|
+
.workspace.has-results { padding-top: 64px; }
|
|
55
|
+
.has-results .home { width: min(680px, 100%); transform: none; }
|
|
56
|
+
.has-results h1 { font-size: 20px; margin-bottom: 18px; }
|
|
57
|
+
.results { max-width: 680px; margin: 44px auto 0; animation: reveal .18s ease-out; }
|
|
58
|
+
.results-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
|
|
59
|
+
h2 { font-size: 18px; font-weight: 500; margin: 0; }
|
|
60
|
+
#result-count, .retrieval-note, .source-meta { font-size: 12px; color: #aaa; line-height: 1.6; }
|
|
61
|
+
.retrieval-note { margin: 12px 0 22px; }
|
|
62
|
+
.source { padding: 22px 0; border-top: 1px solid #ffffff12; }
|
|
63
|
+
.source h3 { font-size: 16px; font-weight: 500; margin: 8px 0 12px; }
|
|
64
|
+
.source p { font-size: 14px; color: #d0d0d0; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0 0 14px; }
|
|
65
|
+
.source a { color: #a7d7e5; text-decoration: none; font-size: 12px; }
|
|
66
|
+
.source a:hover { text-decoration: underline; }
|
|
67
|
+
.stale { color: #e6c58a; }
|
|
68
|
+
.empty { padding: 24px 0; color: #bababa; line-height: 1.7; font-size: 14px; }
|
|
69
|
+
dialog { width: min(460px, calc(100% - 40px)); max-height: 80dvh; padding: 24px; color: #eee; background: #262626; border: 1px solid #484848; border-radius: 20px; box-shadow: 0 20px 80px #0008; }
|
|
70
|
+
dialog::backdrop { background: #0007; }
|
|
71
|
+
dialog header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
|
72
|
+
#close-panel { width: 32px; height: 32px; background: transparent; border: 0; border-radius: 8px; color: #bbb; font-size: 24px; }
|
|
73
|
+
#close-panel:hover { background: #ffffff0c; }
|
|
74
|
+
#panel-body { color: #bbb; font-size: 14px; line-height: 1.7; }
|
|
75
|
+
#panel-body p { margin: 12px 0; }
|
|
76
|
+
.course-item { display: block; width: 100%; text-align: left; background: transparent; border: 0; border-top: 1px solid #ffffff12; padding: 16px 4px; color: #eee; line-height: 1.6; }
|
|
77
|
+
.course-item:hover { background: #ffffff06; }
|
|
78
|
+
.course-item small { display: block; color: #999; }
|
|
79
|
+
code { background: #1a1a1a; border-radius: 4px; padding: 3px 6px; font-size: 12px; }
|
|
80
|
+
.model-sharing { color: #aaa; font-size: 11px; line-height: 1.5; margin: 18px 12px 0; }
|
|
81
|
+
#panel-body a { color: #a7d7e5; }
|
|
82
|
+
.account-action { border: 1px solid #555; background: #333; color: #eee; border-radius: 8px; padding: 10px 14px; }
|
|
83
|
+
.account-action { display: block; margin: 12px 0; }
|
|
84
|
+
#answer-text { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; color: #ddd; }
|
|
85
|
+
.auth-page { min-height: 100svh; display: grid; place-items: center; padding: 40px 24px; }
|
|
86
|
+
.auth-content { width: min(480px, 100%); }
|
|
87
|
+
.auth-brand { display: inline-flex; align-items: center; gap: 12px; color: #eee; text-decoration: none; font-size: 20px; margin-bottom: 64px; }
|
|
88
|
+
.auth-brand img { display: block; }
|
|
89
|
+
.auth-status { color: #a7d7e5; font-size: 13px; margin: 0 0 16px; }
|
|
90
|
+
.auth-content h1 { text-align: left; font-size: 32px; line-height: 1.2; letter-spacing: -.6px; margin: 0; }
|
|
91
|
+
.auth-message { color: #bbb; font-size: 15px; line-height: 1.7; margin: 20px 0 32px; }
|
|
92
|
+
.auth-return { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 20px; border: 1px solid #555; border-radius: 12px; background: #303030; color: #eee; text-decoration: none; }
|
|
93
|
+
.auth-return:hover { background: #383838; border-color: #777; }
|
|
94
|
+
.auth-usage { display: inline-block; margin-top: 24px; color: #aaa; font-size: 13px; text-underline-offset: 4px; }
|
|
95
|
+
@keyframes reveal { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
96
|
+
.home-widgets { max-width: 1120px; margin: 64px auto 0; }
|
|
97
|
+
.has-results .home-widgets { display: none; }
|
|
98
|
+
.widgets-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
|
|
99
|
+
.eyebrow { color: #999; font: 10px / 1.4 ui-monospace, 'Cascadia Mono', monospace; letter-spacing: 1px; margin: 0 0 8px; }
|
|
100
|
+
#widgets-heading { font-size: 25px; font-weight: 400; letter-spacing: -.5px; }
|
|
101
|
+
.widget-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
102
|
+
.widget-actions button, .widget-tools button, .pin-shortcut, .shortcut-row > button:not(.shortcut-link) { background: #ffffff08; border: 1px solid #ffffff1c; border-radius: 8px; color: #ddd; padding: 9px 12px; font-size: 12px; }
|
|
103
|
+
.widget-actions button:hover, .widget-tools button:hover, .pin-shortcut:hover { background: #ffffff14; }
|
|
104
|
+
#customize-widgets[aria-pressed=true] { background: #a7d7e5; color: #17252a; }
|
|
105
|
+
.widget-help, .widget-status { font-size: 12px; color: #aaa; line-height: 1.6; }
|
|
106
|
+
.widget-help { margin: 16px 0 24px; }
|
|
107
|
+
.widget-status { min-height: 20px; }
|
|
108
|
+
.widget-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: 160px; gap: 16px; }
|
|
109
|
+
.widget { container-type: inline-size; position: relative; isolation: isolate; min-width: 0; display: flex; flex-direction: column; border: 1px solid #ffffff12; border-radius: 7px; background: #171819; padding: 18px 22px; overflow: hidden; box-shadow: inset 0 1px #ffffff0c, 0 2px 3px #0003, 0 12px 24px -12px #0009; transition: border-color .2s, box-shadow .2s, translate .2s; animation: reveal .25s ease-out; }
|
|
110
|
+
.widget::before { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none; opacity: .22; background: repeating-linear-gradient(0deg, transparent 0 2px, #ffffff0b 2px 3px), repeating-linear-gradient(91deg, transparent 0 3px, #0004 3px 4px); }
|
|
111
|
+
.widget:hover, .widget:focus-within { border-color: #ffffff30; box-shadow: inset 0 1px #ffffff12, 0 3px 5px #0004, 0 18px 32px -12px #000a; }
|
|
112
|
+
.widget:not(.widget-preview):hover { translate: 0 -2px; }
|
|
113
|
+
.editing .widget:not(.widget-preview):hover { translate: none; }
|
|
114
|
+
.widget-edition { position: relative; z-index: 1; display: flex; justify-content: space-between; gap: 10px; flex-shrink: 0; margin-bottom: 12px; color: #a3a5a5; font: 9px / 1 ui-monospace, 'Cascadia Mono', monospace; letter-spacing: 1.2px; }
|
|
115
|
+
.widget-edition span:last-child { letter-spacing: -.5px; font-weight: 700; }
|
|
116
|
+
.widget-mark { position: absolute; z-index: -1; pointer-events: none; right: -16px; top: 6%; width: 52%; height: 90%; opacity: .5; background-position: center; background-size: contain; background-repeat: no-repeat; }
|
|
117
|
+
.widget-announcements .widget-mark { background-image: url('/assets/widget-announcements.svg'); }
|
|
118
|
+
.widget-shortcuts .widget-mark { background-image: url('/assets/widget-shortcuts.svg'); right: -32px; }
|
|
119
|
+
.widget-deadlines .widget-mark { display: flex; flex-direction: column; align-items: center; justify-content: center; color: #b1d9e8; opacity: .7; transform: rotate(-7deg); }
|
|
120
|
+
.widget-deadlines .widget-mark span { font-size: clamp(72px, 28cqi, 180px); font-weight: 850; letter-spacing: -.09em; line-height: .78; background: radial-gradient(circle, #b1d9e8 1px, transparent 1.2px) 0 0 / 3px 3px; background-clip: text; color: transparent; }
|
|
121
|
+
.widget-deadlines .widget-mark small { margin-top: 12px; padding-left: 12px; font: 10px / 1 ui-monospace, monospace; letter-spacing: 7px; }
|
|
122
|
+
.widget-header { position: relative; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-top: auto; margin-bottom: 9px; flex-shrink: 0; }
|
|
123
|
+
.widget h3 { color: #dededb; font-size: clamp(17px, 4.4cqi, 25px); line-height: 1.05; letter-spacing: -.8px; font-weight: 750; margin: 0; }
|
|
124
|
+
.widget-body { position: relative; width: 72%; overflow: auto; min-height: 0; scrollbar-width: thin; scrollbar-color: #555 transparent; }
|
|
125
|
+
.widget-item { display: block; text-align: left; width: 100%; background: none; border: 0; border-top: 1px solid #ffffff12; padding: 12px 0; color: #eee; }
|
|
126
|
+
.widget-item:first-child { border-top: 0; padding-top: 2px; }
|
|
127
|
+
.widget-item:hover strong { color: #a7d7e5; }
|
|
128
|
+
.widget-item small, .widget-item strong, .widget-item span { display: block; overflow-wrap: anywhere; }
|
|
129
|
+
.widget-item small { font: 10px / 1.5 ui-monospace, 'Cascadia Mono', monospace; color: #b1b3b3; margin-bottom: 4px; }
|
|
130
|
+
.widget-item strong { font-size: 14px; font-weight: 500; line-height: 1.4; }
|
|
131
|
+
.widget-item span { color: #c3c4c2; font-size: 12px; line-height: 1.6; margin-top: 4px; }
|
|
132
|
+
.widget-empty { font-size: 13px; line-height: 1.6; color: #aaa; margin: 0 0 12px; }
|
|
133
|
+
.shortcut-row { display: flex; align-items: center; gap: 8px; border-top: 1px solid #ffffff12; }
|
|
134
|
+
.shortcut-row:first-child { border-top: 0; }
|
|
135
|
+
.shortcut-link { flex: 1; min-width: 0; background: none; border: 0; text-align: left; color: #d0d1ce; font: 11px / 1.5 ui-monospace, 'Cascadia Mono', monospace; padding: 8px 0; overflow-wrap: anywhere; }
|
|
136
|
+
.shortcut-link::after { content: ' ↗'; color: #a7d7e5; }
|
|
137
|
+
.pin-shortcut { white-space: nowrap; }
|
|
138
|
+
.widget .pin-shortcut, .widget-tools button { color: #bfc0bb; font: 9px / 1.4 ui-monospace, monospace; border-radius: 3px; padding: 5px 7px; background: #ffffff06; }
|
|
139
|
+
.editing { min-height: 688px; background: repeating-linear-gradient(0deg, #ffffff03 0px, #ffffff03 160px, transparent 160px, transparent 176px); border-radius: 18px; }
|
|
140
|
+
.widget-tools { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; flex-shrink: 0; }
|
|
141
|
+
.widget-tools button { padding: 5px 8px; }
|
|
142
|
+
.widget-drag { cursor: grab; touch-action: none; background: none; border: 0; color: #aaa; font-size: 24px; padding: 0 6px; }
|
|
143
|
+
.editing .widget { cursor: grab; touch-action: none; user-select: none; animation: none; }
|
|
144
|
+
.editing .widget.dragging { z-index: 4; cursor: grabbing; border-color: #a7d7e5; box-shadow: 0 16px 40px #0008; will-change: transform; transition: none; }
|
|
145
|
+
.widget-preview { pointer-events: none; background: #a7d7e51a; border: 1px dashed #a7d7e5; box-shadow: none; animation: none; }
|
|
146
|
+
.widget-preview::before { display: none; }
|
|
147
|
+
.widget-preview.blocked { background: #e8909014; border-color: #e89090; }
|
|
148
|
+
.widget.dragging.blocked { border-color: #e89090; }
|
|
149
|
+
.widget-resize { position: absolute; z-index: 2; padding: 0; border: 0; background: transparent; touch-action: none; }
|
|
150
|
+
.widget-resize:hover, .widget-resize:focus-visible { background: #a7d7e550; }
|
|
151
|
+
.resize-n, .resize-s { left: 14px; right: 14px; height: 10px; cursor: ns-resize; }
|
|
152
|
+
.resize-n { top: 0; } .resize-s { bottom: 0; }
|
|
153
|
+
.resize-e, .resize-w { top: 14px; bottom: 14px; width: 10px; cursor: ew-resize; }
|
|
154
|
+
.resize-e { right: 0; } .resize-w { left: 0; }
|
|
155
|
+
.resize-ne, .resize-nw, .resize-se, .resize-sw { width: 16px; height: 16px; }
|
|
156
|
+
.resize-ne, .resize-sw { cursor: nesw-resize; } .resize-nw, .resize-se { cursor: nwse-resize; }
|
|
157
|
+
.resize-ne { top: 0; right: 0; } .resize-nw { top: 0; left: 0; }
|
|
158
|
+
.resize-se { bottom: 0; right: 0; border-right: 2px solid #a7d7e580; border-bottom: 2px solid #a7d7e580; border-radius: 0 0 7px 0; }
|
|
159
|
+
.resize-sw { bottom: 0; left: 0; }
|
|
160
|
+
.col-0 { grid-column-start: 1; } .col-1 { grid-column-start: 2; } .col-2 { grid-column-start: 3; } .col-3 { grid-column-start: 4; }
|
|
161
|
+
.row-0 { grid-row-start: 1; } .row-1 { grid-row-start: 2; } .row-2 { grid-row-start: 3; } .row-3 { grid-row-start: 4; }
|
|
162
|
+
.row-4 { grid-row-start: 5; } .row-5 { grid-row-start: 6; } .row-6 { grid-row-start: 7; } .row-7 { grid-row-start: 8; }
|
|
163
|
+
.size-small { grid-column-end: span 1; grid-row-end: span 1; }
|
|
164
|
+
.size-wide { grid-column-end: span 2; grid-row-end: span 1; }
|
|
165
|
+
.size-large { grid-column-end: span 2; grid-row-end: span 2; }
|
|
166
|
+
.size-full { grid-column-end: span 4; grid-row-end: span 2; }
|
|
167
|
+
#ai-answer { max-width: 1000px; }
|
|
168
|
+
#ai-answer-title { font-size: 28px; font-weight: 700; letter-spacing: -.7px; overflow-wrap: anywhere; }
|
|
169
|
+
#answer-text { max-width: 720px; }
|
|
170
|
+
.answer-view { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 16px; margin: 28px 0 40px; }
|
|
171
|
+
.widget.generated-card { min-height: 0; max-height: none; padding: 24px; }
|
|
172
|
+
.generated-card::after { content: ''; pointer-events: none; position: absolute; z-index: -1; right: -20px; top: 0; width: 180px; height: 180px; background: url('/assets/widget-announcements.svg') center / contain no-repeat; opacity: .07; }
|
|
173
|
+
.generated-card.answer-checklist::after { background-image: url('/assets/widget-shortcuts.svg'); }
|
|
174
|
+
.generated-card .widget-edition { margin-bottom: 28px; }
|
|
175
|
+
.generated-card .widget-header { margin: 0 0 14px; }
|
|
176
|
+
.generated-card h3 { font-size: 23px; line-height: 1.2; overflow-wrap: anywhere; }
|
|
177
|
+
.generated-card p, .generated-card li { font-size: 14px; color: #d0d1ce; line-height: 1.65; overflow-wrap: anywhere; }
|
|
178
|
+
.answer-progress, .answer-session-note { color: #aaa; font: 11px / 1.5 ui-monospace, monospace; }
|
|
179
|
+
.answer-checklist { list-style: none; margin: 4px 0 20px; padding: 0; }
|
|
180
|
+
.answer-checklist li { border-top: 1px solid #ffffff12; padding: 16px 0; }
|
|
181
|
+
.answer-checklist label { display: flex; align-items: flex-start; gap: 12px; cursor: pointer; }
|
|
182
|
+
.answer-checklist input { appearance: auto; flex: 0 0 18px; width: 18px; height: 18px; margin: 3px 0 0; accent-color: #a7d7e5; }
|
|
183
|
+
.answer-checklist input:focus-visible { outline: 2px solid #a7d7e5; outline-offset: 4px; }
|
|
184
|
+
.answer-checklist .completed label span { text-decoration: line-through; color: #888; }
|
|
185
|
+
.answer-references { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
186
|
+
.answer-references a { color: #b1d9e8; font: 10px / 1.5 ui-monospace, monospace; text-decoration: none; padding: 3px 6px; border: 1px solid #ffffff1a; border-radius: 3px; }
|
|
187
|
+
.answer-references a:hover { background: #ffffff09; text-decoration: underline; }
|
|
188
|
+
.answer-checklist .answer-references { margin-left: 30px; }
|
|
189
|
+
.answer-comparison { grid-column: 1 / -1; }
|
|
190
|
+
.answer-table-scroll { overflow-x: auto; }
|
|
191
|
+
.answer-table-scroll table { border-collapse: collapse; width: 100%; min-width: 420px; text-align: left; color: #d0d1ce; font-size: 13px; }
|
|
192
|
+
.answer-table-scroll th, .answer-table-scroll td { padding: 16px 12px; border-top: 1px solid #ffffff16; vertical-align: top; overflow-wrap: anywhere; max-width: 320px; white-space: pre-wrap; }
|
|
193
|
+
.answer-table-scroll thead th { font: 10px / 1.5 ui-monospace, monospace; color: #aaa; }
|
|
194
|
+
.answer-table-scroll tbody th { font-weight: 500; }
|
|
195
|
+
.answer-table-scroll .answer-references { margin: 0; }
|
|
196
|
+
.answer-source-cards > section { padding: 18px 0; border-top: 1px solid #ffffff12; }
|
|
197
|
+
.answer-source-cards h4 { margin: 0 0 8px; font-size: 16px; letter-spacing: -.3px; }
|
|
198
|
+
.answer-source-cards p { margin: 0; white-space: pre-wrap; }
|
|
199
|
+
.answer-sources-heading { font: 11px / 1.5 ui-monospace, monospace; color: #aaa; margin: 32px 0 12px; }
|
|
200
|
+
#answer-sources { max-width: 720px; }
|
|
201
|
+
#answer-sources .source { scroll-margin-top: 24px; }
|
|
202
|
+
@container (max-width: 300px) {
|
|
203
|
+
.widget-mark { opacity: .17; width: 65%; }
|
|
204
|
+
.widget-body { width: 100%; }
|
|
205
|
+
.widget h3 { font-size: 17px; letter-spacing: -.4px; }
|
|
206
|
+
}
|
|
207
|
+
@media (max-width: 760px) {
|
|
208
|
+
.widget-grid { display: flex; flex-direction: column; min-height: 0; }
|
|
209
|
+
.widget { max-height: 360px; min-height: 160px; }
|
|
210
|
+
.widget.size-large, .widget.size-full { min-height: 260px; }
|
|
211
|
+
.home-widgets { overflow-x: auto; }
|
|
212
|
+
.widget-grid.editing { display: grid; width: 800px; min-height: 688px; }
|
|
213
|
+
.widget-grid.editing .widget { min-height: 0; max-height: none; }
|
|
214
|
+
.widgets-heading { align-items: flex-start; flex-direction: column; gap: 14px; }
|
|
215
|
+
.home-widgets { margin-top: 40px; }
|
|
216
|
+
}
|
|
217
|
+
@media (max-width: 700px) {
|
|
218
|
+
.workspace { padding: 80px 20px 40px 80px; }
|
|
219
|
+
.home { transform: none; }
|
|
220
|
+
h1 { font-size: 20px; line-height: 1.4; margin-bottom: 14px; letter-spacing: -.3px; }
|
|
221
|
+
.composer { padding: 16px; height: 120px; border-radius: 32px; }
|
|
222
|
+
.workspace.has-results { padding-top: 72px; }
|
|
223
|
+
select { max-width: 90%; }
|
|
224
|
+
}
|
|
225
|
+
.assignment-workspace { max-width: 1000px; }
|
|
226
|
+
.assignment-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 16px; margin-top: 16px; }
|
|
227
|
+
.assignment-workspace .widget { padding: 24px; min-width: 0; min-height: 0; max-height: none; height: auto; overflow-wrap: anywhere; }
|
|
228
|
+
.assignment-workspace .assignment-overview { background: #203238; border-color: #547982; }
|
|
229
|
+
.assignment-workspace h3 { margin: 8px 0 16px; }
|
|
230
|
+
.task-section { margin-top: 32px; }
|
|
231
|
+
.canvas-workspace { max-width: 1120px; }
|
|
232
|
+
.canvas-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
|
|
233
|
+
.generated-canvas { display:block; width:100%; height:600px; border:0; border-top:1px solid #ffffff18; margin-top:20px; background:#222; }
|
|
234
|
+
.canvas-source { margin-top:24px; padding:24px 0; border-top:1px solid #ffffff25; }
|
|
235
|
+
.canvas-source a { color:#a7d7e5; }
|
|
236
|
+
.assignment-card h4 { font-size: 17px; margin: 8px 0 16px; }
|
|
237
|
+
.assignment-workspace a { color: #a7d7e5; }
|
|
238
|
+
.assignment-workspace summary { cursor: pointer; padding: 8px 0; }
|
|
239
|
+
.assignment-text { white-space: pre-wrap; line-height: 1.65; max-height: 440px; overflow-y: auto; }
|
|
240
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
|
241
|
+
@media (prefers-reduced-motion: reduce) { .dot-loader > span { opacity: calc(.95 - var(--ring) * .125); will-change: auto; } }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const sizes = { small: [1, 1], wide: [2, 1], large: [2, 2], full: [4, 2],
|
|
2
|
+
...Object.fromEntries(Array.from({ length: 32 }, (_, i) => {
|
|
3
|
+
const w = i % 4 + 1, h = Math.floor(i / 4) + 1;
|
|
4
|
+
return [`${w}x${h}`, [w, h]];
|
|
5
|
+
})) };
|
|
6
|
+
export const defaults = () => [
|
|
7
|
+
{ type: 'deadlines', x: 0, y: 0, size: 'wide' },
|
|
8
|
+
{ type: 'announcements', x: 2, y: 0, size: 'large' },
|
|
9
|
+
{ type: 'shortcuts', x: 0, y: 1, size: 'wide' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function fits(layout, widget) {
|
|
13
|
+
if (!Object.hasOwn(sizes, widget.size) || !Number.isInteger(widget.x) || !Number.isInteger(widget.y)) return false;
|
|
14
|
+
const [w, h] = sizes[widget.size];
|
|
15
|
+
if (widget.x < 0 || widget.x + w > 4 || widget.y < 0 || widget.y + h > 8) return false;
|
|
16
|
+
return layout.every(other => {
|
|
17
|
+
if (other.type === widget.type) return true;
|
|
18
|
+
const [ow, oh] = sizes[other.size];
|
|
19
|
+
return widget.x + w <= other.x || other.x + ow <= widget.x || widget.y + h <= other.y || other.y + oh <= widget.y;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function restore(value) {
|
|
24
|
+
if (!value || !Array.isArray(value.layout) || value.layout.length > 3 || !Array.isArray(value.pins)) return null;
|
|
25
|
+
const layout = [];
|
|
26
|
+
for (const item of value.layout) {
|
|
27
|
+
if (!item || !['deadlines', 'announcements', 'shortcuts'].includes(item.type) || layout.some(w => w.type === item.type) || !fits(layout, item)) return null;
|
|
28
|
+
layout.push({ type: item.type, x: item.x, y: item.y, size: item.size });
|
|
29
|
+
}
|
|
30
|
+
return { layout, pins: [...new Set(value.pins.filter(pin => typeof pin === 'string' && pin.length < 2000))].slice(0, 20) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function firstSpace(layout, type, size = 'wide') {
|
|
34
|
+
for (let y = 0; y < 8; y++) for (let x = 0; x < 4; x++) {
|
|
35
|
+
const widget = { type, size, x, y };
|
|
36
|
+
if (fits(layout, widget)) return widget;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
package/ui/widgets.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { defaults, fits, firstSpace, restore, sizes } from './widget-layout.js';
|
|
2
|
+
import { startLoading } from './loading.js';
|
|
3
|
+
|
|
4
|
+
const $ = id => document.getElementById(id);
|
|
5
|
+
const names = { deadlines: 'Upcoming deadlines', announcements: 'Class announcements', shortcuts: 'Your shortcuts' };
|
|
6
|
+
const node = (tag, text, cls) => {
|
|
7
|
+
const el = document.createElement(tag);
|
|
8
|
+
if (text !== undefined) el.textContent = text;
|
|
9
|
+
if (cls) el.className = cls;
|
|
10
|
+
return el;
|
|
11
|
+
};
|
|
12
|
+
const button = (label, action, cls) => {
|
|
13
|
+
const el = node('button', label, cls);
|
|
14
|
+
el.type = 'button';
|
|
15
|
+
el.addEventListener('click', action);
|
|
16
|
+
return el;
|
|
17
|
+
};
|
|
18
|
+
const date = value => new Date(value).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
|
|
19
|
+
|
|
20
|
+
export function createWidgets(browse) {
|
|
21
|
+
let data, layout = [], pins = [], editing = false, storageKey;
|
|
22
|
+
const message = text => { $('widget-status').textContent = text; };
|
|
23
|
+
function save() {
|
|
24
|
+
try { localStorage.setItem(storageKey, JSON.stringify({ layout, pins })); }
|
|
25
|
+
catch { message('Your changes work for this visit, but this browser could not save them.'); }
|
|
26
|
+
}
|
|
27
|
+
function move(widget, change) {
|
|
28
|
+
const next = { ...widget, ...change };
|
|
29
|
+
if (!fits(layout, next)) { message('That space is occupied or outside the grid. Move another widget first.'); return; }
|
|
30
|
+
Object.assign(widget, next);
|
|
31
|
+
message(`${names[widget.type]} moved to column ${widget.x + 1}, row ${widget.y + 1}.`);
|
|
32
|
+
render(); save();
|
|
33
|
+
$(`widget-${widget.type}`)?.focus();
|
|
34
|
+
}
|
|
35
|
+
function place(tile, widget) {
|
|
36
|
+
tile.className = `widget widget-${widget.type} col-${widget.x} row-${widget.y} size-${widget.size}`;
|
|
37
|
+
const [w, h] = sizes[widget.size];
|
|
38
|
+
tile.style.gridColumnEnd = `span ${w}`;
|
|
39
|
+
tile.style.gridRowEnd = `span ${h}`;
|
|
40
|
+
}
|
|
41
|
+
function customize(tile, widget) {
|
|
42
|
+
tile.tabIndex = 0;
|
|
43
|
+
tile.setAttribute('aria-describedby', 'widget-help');
|
|
44
|
+
tile.addEventListener('keydown', event => {
|
|
45
|
+
if (event.target !== tile && !event.target.classList.contains('widget-resize')) return;
|
|
46
|
+
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
|
47
|
+
if (!delta) return;
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
const [dx, dy] = delta, [w, h] = sizes[widget.size];
|
|
50
|
+
move(widget, event.shiftKey || event.target !== tile ? { size: `${w + dx}x${h + dy}` } : { x: widget.x + dx, y: widget.y + dy });
|
|
51
|
+
});
|
|
52
|
+
tile.addEventListener('click', event => {
|
|
53
|
+
if (event.target.closest('.widget-item, .shortcut-link')) { event.preventDefault(); event.stopPropagation(); }
|
|
54
|
+
}, true);
|
|
55
|
+
tile.addEventListener('pointerdown', event => {
|
|
56
|
+
const edge = event.target.closest('.widget-resize')?.dataset.edge;
|
|
57
|
+
if (event.button !== 0 || (!edge && event.target.closest('button:not(.widget-drag):not(.widget-item):not(.shortcut-link), a, input, select'))) return;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
tile.focus({ preventScroll: true });
|
|
60
|
+
const grid = $('widget-grid'), rect = tile.getBoundingClientRect();
|
|
61
|
+
const cell = (grid.getBoundingClientRect().width + 16) / 4;
|
|
62
|
+
const startX = event.clientX, startY = event.clientY, scrollX = window.scrollX, scrollY = window.scrollY;
|
|
63
|
+
const [w, h] = sizes[widget.size];
|
|
64
|
+
const preview = node('div');
|
|
65
|
+
preview.setAttribute('aria-hidden', 'true');
|
|
66
|
+
let next = { ...widget }, changed = false;
|
|
67
|
+
grid.append(preview);
|
|
68
|
+
function update(e) {
|
|
69
|
+
const dx = e.clientX - startX + window.scrollX - scrollX, dy = e.clientY - startY + window.scrollY - scrollY;
|
|
70
|
+
changed ||= Math.abs(dx) + Math.abs(dy) > 4;
|
|
71
|
+
const left = edge?.includes('w') ? dx : 0, top = edge?.includes('n') ? dy : 0;
|
|
72
|
+
const width = Math.max(80, rect.width + (edge?.includes('e') ? dx : -left));
|
|
73
|
+
const height = Math.max(80, rect.height + (edge?.includes('s') ? dy : -top));
|
|
74
|
+
tile.style.transform = `translate(${edge ? left : dx}px, ${edge ? top : dy}px)`;
|
|
75
|
+
if (edge) { tile.style.width = `${width}px`; tile.style.height = `${height}px`; }
|
|
76
|
+
const x = widget.x + Math.round((edge ? left : dx) / cell);
|
|
77
|
+
const y = widget.y + Math.round((edge ? top : dy) / 176);
|
|
78
|
+
next = { ...widget, x, y, size: edge ? `${Math.round((width + 16) / cell)}x${Math.round((height + 16) / 176)}` : widget.size };
|
|
79
|
+
const valid = fits(layout, next);
|
|
80
|
+
// Invalid dimensions have no grid span; keep the last cell preview and mark it blocked.
|
|
81
|
+
if (Object.hasOwn(sizes, next.size) && x >= 0 && x < 4 && y >= 0 && y < 8) place(preview, next);
|
|
82
|
+
preview.classList.add('widget-preview');
|
|
83
|
+
preview.classList.toggle('blocked', !valid);
|
|
84
|
+
tile.classList.toggle('blocked', !valid);
|
|
85
|
+
}
|
|
86
|
+
function finish(commit) {
|
|
87
|
+
tile.removeEventListener('pointermove', update);
|
|
88
|
+
tile.removeEventListener('pointerup', up);
|
|
89
|
+
tile.removeEventListener('pointercancel', cancel);
|
|
90
|
+
tile.removeEventListener('lostpointercapture', cancel);
|
|
91
|
+
document.removeEventListener('keydown', escape);
|
|
92
|
+
window.removeEventListener('blur', cancel);
|
|
93
|
+
preview.remove();
|
|
94
|
+
if (tile.hasPointerCapture(event.pointerId)) tile.releasePointerCapture(event.pointerId);
|
|
95
|
+
if (commit && changed) move(widget, next);
|
|
96
|
+
// A canceled or blocked gesture restores the original geometry without saving.
|
|
97
|
+
if (tile.isConnected) { tile.removeAttribute('style'); place(tile, widget); }
|
|
98
|
+
}
|
|
99
|
+
const up = e => { update(e); finish(true); };
|
|
100
|
+
const cancel = () => finish(false);
|
|
101
|
+
const escape = e => { if (e.key === 'Escape') { e.preventDefault(); cancel(); } };
|
|
102
|
+
tile.setPointerCapture(event.pointerId);
|
|
103
|
+
tile.classList.add('dragging');
|
|
104
|
+
tile.addEventListener('pointermove', update);
|
|
105
|
+
tile.addEventListener('pointerup', up);
|
|
106
|
+
tile.addEventListener('pointercancel', cancel);
|
|
107
|
+
tile.addEventListener('lostpointercapture', cancel);
|
|
108
|
+
document.addEventListener('keydown', escape);
|
|
109
|
+
window.addEventListener('blur', cancel);
|
|
110
|
+
update(event);
|
|
111
|
+
});
|
|
112
|
+
for (const edge of ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw']) {
|
|
113
|
+
const grip = button('', () => {}, `widget-resize resize-${edge}`);
|
|
114
|
+
grip.tabIndex = -1;
|
|
115
|
+
grip.dataset.edge = edge;
|
|
116
|
+
grip.setAttribute('aria-label', `Resize ${names[widget.type]} ${edge}`);
|
|
117
|
+
tile.append(grip);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function choose(title, items, action) {
|
|
121
|
+
$('panel-title').textContent = title;
|
|
122
|
+
$('panel-body').replaceChildren();
|
|
123
|
+
const filter = node('input');
|
|
124
|
+
filter.type = 'search';
|
|
125
|
+
filter.placeholder = 'Find a class or material…';
|
|
126
|
+
filter.setAttribute('aria-label', 'Find a class or material');
|
|
127
|
+
const list = node('div');
|
|
128
|
+
const show = () => {
|
|
129
|
+
list.replaceChildren();
|
|
130
|
+
for (const item of items.filter(item => item.label.toLowerCase().includes(filter.value.toLowerCase()))) list.append(button(item.label, () => {
|
|
131
|
+
action(item); $('panel').close();
|
|
132
|
+
}, 'course-item'));
|
|
133
|
+
if (!list.children.length) list.append(node('p', 'No available items.'));
|
|
134
|
+
};
|
|
135
|
+
if (items.length > 3) $('panel-body').append(filter);
|
|
136
|
+
$('panel-body').append(list);
|
|
137
|
+
filter.addEventListener('input', show);
|
|
138
|
+
show();
|
|
139
|
+
$('panel').showModal();
|
|
140
|
+
}
|
|
141
|
+
function pinPicker() {
|
|
142
|
+
const items = [
|
|
143
|
+
...data.courses.map(c => ({ key: `course:${c.id}`, label: c.name })),
|
|
144
|
+
...data.sources.map(s => ({ key: `source:${s.ref}`, label: `${s.courseName} · ${s.title}` })),
|
|
145
|
+
];
|
|
146
|
+
const unique = [...new Map(items.map(item => [item.key, item])).values()];
|
|
147
|
+
choose('Pin a class or course material', unique.filter(item => !pins.includes(item.key)), item => {
|
|
148
|
+
if (pins.length >= 20) { message('You can pin up to 20 shortcuts. Remove one to add another.'); return; }
|
|
149
|
+
pins.push(item.key); save(); render();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function sourceRow(source, detail) {
|
|
153
|
+
const row = button('', () => browse(source.course, source.title), 'widget-item');
|
|
154
|
+
row.append(node('small', `${source.courseName}${source.stale ? ' · Stale cache' : ''}`), node('strong', source.title));
|
|
155
|
+
if (detail) row.append(node('span', detail));
|
|
156
|
+
return row;
|
|
157
|
+
}
|
|
158
|
+
function render() {
|
|
159
|
+
if (!data) return;
|
|
160
|
+
const grid = $('widget-grid');
|
|
161
|
+
grid.replaceChildren();
|
|
162
|
+
grid.classList.toggle('editing', editing);
|
|
163
|
+
$('customize-widgets').textContent = editing ? 'Done' : 'Customize';
|
|
164
|
+
$('customize-widgets').setAttribute('aria-pressed', String(editing));
|
|
165
|
+
$('add-widget').hidden = !editing;
|
|
166
|
+
$('widget-help').textContent = editing ? 'Drag a widget to move it. Drag its edges or corners to resize. Arrow keys move a focused widget; Shift + arrows resize. Escape cancels a drag. Changes save automatically.' : 'From your cached coursework · Select an item to browse its source.';
|
|
167
|
+
for (const widget of [...layout].sort((a, b) => a.y - b.y || a.x - b.x)) {
|
|
168
|
+
const tile = node('article', undefined, `widget widget-${widget.type} col-${widget.x} row-${widget.y} size-${widget.size}`);
|
|
169
|
+
place(tile, widget);
|
|
170
|
+
tile.id = `widget-${widget.type}`;
|
|
171
|
+
tile.tabIndex = -1;
|
|
172
|
+
tile.setAttribute('aria-label', names[widget.type]);
|
|
173
|
+
const edition = node('div', undefined, 'widget-edition');
|
|
174
|
+
edition.append(node('span', { deadlines: '01 / PLANNER', announcements: '02 / BULLETIN', shortcuts: '03 / COLLECTION' }[widget.type]), node('span', 'dd2l'));
|
|
175
|
+
const mark = node('div', undefined, 'widget-mark');
|
|
176
|
+
mark.setAttribute('aria-hidden', 'true');
|
|
177
|
+
if (widget.type === 'deadlines') {
|
|
178
|
+
const due = data.deadlines[0]?.dueDate;
|
|
179
|
+
mark.append(node('span', due ? new Date(due).toLocaleDateString([], { day: '2-digit' }) : '—'),
|
|
180
|
+
node('small', due ? new Date(due).toLocaleDateString([], { month: 'short' }).toUpperCase() : 'NO DUE DATE'));
|
|
181
|
+
}
|
|
182
|
+
tile.append(edition, mark);
|
|
183
|
+
const header = node('header', undefined, 'widget-header');
|
|
184
|
+
header.append(node('h3', names[widget.type]));
|
|
185
|
+
if (widget.type === 'shortcuts') header.append(button('+ Pin shortcut', pinPicker, 'pin-shortcut'));
|
|
186
|
+
if (editing) {
|
|
187
|
+
const handle = button('⠿', () => {}, 'widget-drag');
|
|
188
|
+
handle.setAttribute('aria-label', `Drag ${names[widget.type]}`);
|
|
189
|
+
handle.tabIndex = -1;
|
|
190
|
+
header.append(handle);
|
|
191
|
+
}
|
|
192
|
+
tile.append(header);
|
|
193
|
+
if (editing) {
|
|
194
|
+
const controls = node('div', undefined, 'widget-tools');
|
|
195
|
+
controls.append(button('Remove', () => {
|
|
196
|
+
layout = layout.filter(w => w !== widget); save(); render(); $('add-widget').focus();
|
|
197
|
+
}));
|
|
198
|
+
tile.append(controls);
|
|
199
|
+
}
|
|
200
|
+
const body = node('div', undefined, 'widget-body');
|
|
201
|
+
if (widget.type === 'shortcuts') {
|
|
202
|
+
for (const pin of pins) {
|
|
203
|
+
const course = data.courses.find(c => pin === `course:${c.id}`);
|
|
204
|
+
const source = data.sources.find(s => pin === `source:${s.ref}`);
|
|
205
|
+
if (!course && !source) continue;
|
|
206
|
+
const row = node('div', undefined, 'shortcut-row');
|
|
207
|
+
row.append(button(course?.name || source.title, () => browse(course?.id || source.course, source?.title || ''), 'shortcut-link'));
|
|
208
|
+
if (editing) {
|
|
209
|
+
const remove = button('×', () => { pins = pins.filter(p => p !== pin); save(); render(); });
|
|
210
|
+
remove.setAttribute('aria-label', `Unpin ${course?.name || source.title}`);
|
|
211
|
+
row.append(remove);
|
|
212
|
+
}
|
|
213
|
+
body.append(row);
|
|
214
|
+
}
|
|
215
|
+
if (!body.children.length) body.append(node('p', 'Keep the classes and materials you use most within reach.', 'widget-empty'));
|
|
216
|
+
} else {
|
|
217
|
+
for (const source of data[widget.type]) body.append(sourceRow(source, widget.type === 'deadlines' ? `Due ${date(source.dueDate)}` : `${source.publishedAt ? date(source.publishedAt) + ' · ' : ''}${source.excerpt}`));
|
|
218
|
+
if (!body.children.length) body.append(node('p', widget.type === 'deadlines' ? 'No upcoming deadlines in the cache. Sync onQ to check for updates.' : 'No announcements in the cache. Sync onQ to check for updates.', 'widget-empty'));
|
|
219
|
+
}
|
|
220
|
+
tile.append(body);
|
|
221
|
+
if (editing) customize(tile, widget);
|
|
222
|
+
grid.append(tile);
|
|
223
|
+
}
|
|
224
|
+
if (!layout.length) grid.append(node('p', 'Your homescreen is clear. Choose Add widget to get started.', 'widget-empty'));
|
|
225
|
+
}
|
|
226
|
+
$('customize-widgets').addEventListener('click', () => { editing = !editing; render(); });
|
|
227
|
+
$('add-widget').addEventListener('click', () => choose('Add a widget', Object.entries(names)
|
|
228
|
+
.filter(([type]) => !layout.some(w => w.type === type)).map(([type, label]) => ({ type, label })), ({ type }) => {
|
|
229
|
+
const widget = firstSpace(layout, type);
|
|
230
|
+
if (!widget) { message('The grid is full. Remove or resize a widget first.'); return; }
|
|
231
|
+
layout.push(widget); save(); render();
|
|
232
|
+
}));
|
|
233
|
+
return async function refresh() {
|
|
234
|
+
const finish = startLoading($('widget-loading'), 'Refreshing coursework widgets');
|
|
235
|
+
$('refresh-widgets').disabled = true;
|
|
236
|
+
try {
|
|
237
|
+
const response = await fetch('/api/home');
|
|
238
|
+
if (!response.ok) throw new Error('Could not refresh widgets. Try again.');
|
|
239
|
+
const next = await response.json();
|
|
240
|
+
const key = `dd2l:widgets:v1:${next.scope}`;
|
|
241
|
+
if (storageKey !== key) {
|
|
242
|
+
let stored;
|
|
243
|
+
try { stored = restore(JSON.parse(localStorage.getItem(key))); } catch {}
|
|
244
|
+
layout = stored?.layout || defaults();
|
|
245
|
+
pins = stored?.pins || next.courses.slice(0, 2).map(c => `course:${c.id}`);
|
|
246
|
+
storageKey = key;
|
|
247
|
+
}
|
|
248
|
+
data = next; render(); message('Widgets updated from the local cache.');
|
|
249
|
+
} catch (error) { message(error.message); }
|
|
250
|
+
finally { finish(); $('refresh-widgets').disabled = false; }
|
|
251
|
+
};
|
|
252
|
+
}
|