@ivanbard/dd2l 0.0.0-stage → 0.1.0

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/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
+ }