@kenjura/ursa 0.75.0 → 0.77.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/CHANGELOG.md +85 -0
- package/meta/default.css +149 -3
- package/meta/templates/default-template/default.css +1268 -0
- package/meta/{default-template.html → templates/default-template/index.html} +49 -2
- package/meta/{menu.js → templates/default-template/menu.js} +1 -1
- package/meta/templates/default-template/sectionify.js +46 -0
- package/meta/templates/default-template/widgets.js +701 -0
- package/package.json +1 -1
- package/src/dev.js +125 -34
- package/src/helper/assetBundler.js +471 -0
- package/src/helper/build/autoIndex.js +26 -23
- package/src/helper/build/cacheBust.js +79 -0
- package/src/helper/build/navCache.js +4 -0
- package/src/helper/build/templates.js +176 -19
- package/src/helper/build/watchCache.js +7 -0
- package/src/helper/customMenu.js +4 -2
- package/src/helper/dependencyTracker.js +269 -0
- package/src/helper/findScriptJs.js +29 -0
- package/src/helper/findStyleCss.js +29 -0
- package/src/helper/portUtils.js +132 -0
- package/src/jobs/generate.js +276 -59
- package/src/serve.js +446 -162
- package/meta/character-sheet.css +0 -50
- package/meta/widgets.js +0 -376
- /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
- /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
- /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
- /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
- /package/meta/{search.js → templates/default-template/search.js} +0 -0
- /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
- /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
- /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
- /package/meta/{template2.html → templates/template2/index.html} +0 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget system for the top nav panel.
|
|
3
|
+
*
|
|
4
|
+
* Widgets appear as icon buttons in the nav bar. Clicking a button toggles a
|
|
5
|
+
* dropdown panel below the nav. Left-side and right-side widgets have separate
|
|
6
|
+
* dropdown panels. One widget can be open per side at a time.
|
|
7
|
+
*
|
|
8
|
+
* Widget state (open/closed) is persisted in localStorage so it survives page reloads.
|
|
9
|
+
*
|
|
10
|
+
* Built-in widgets:
|
|
11
|
+
* Left: Recent Activity (open by default)
|
|
12
|
+
* Right: TOC, Search, Profile
|
|
13
|
+
*/
|
|
14
|
+
class WidgetManager {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.dropdownRight = document.getElementById('widget-dropdown');
|
|
17
|
+
this.dropdownLeft = document.getElementById('widget-dropdown-left');
|
|
18
|
+
this.buttons = document.querySelectorAll('.widget-button[data-widget]');
|
|
19
|
+
this.activeRight = null;
|
|
20
|
+
this.activeLeft = null;
|
|
21
|
+
|
|
22
|
+
// Widgets that default to open on first visit
|
|
23
|
+
this.defaultOpen = new Set(['recent-activity']);
|
|
24
|
+
|
|
25
|
+
if (this.buttons.length === 0) return;
|
|
26
|
+
|
|
27
|
+
this.init();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get the side (left/right) for a widget based on its button's data-widget-side attribute
|
|
32
|
+
*/
|
|
33
|
+
getSide(widgetName) {
|
|
34
|
+
const btn = document.querySelector(`.widget-button[data-widget="${widgetName}"]`);
|
|
35
|
+
return btn?.dataset.widgetSide === 'left' ? 'left' : 'right';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the dropdown element for a given side
|
|
40
|
+
*/
|
|
41
|
+
getDropdown(side) {
|
|
42
|
+
return side === 'left' ? this.dropdownLeft : this.dropdownRight;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the active widget name for a given side
|
|
47
|
+
*/
|
|
48
|
+
getActive(side) {
|
|
49
|
+
return side === 'left' ? this.activeLeft : this.activeRight;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set the active widget name for a given side
|
|
54
|
+
*/
|
|
55
|
+
setActive(side, widgetName) {
|
|
56
|
+
if (side === 'left') {
|
|
57
|
+
this.activeLeft = widgetName;
|
|
58
|
+
} else {
|
|
59
|
+
this.activeRight = widgetName;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
init() {
|
|
64
|
+
// Bind button clicks
|
|
65
|
+
this.buttons.forEach(btn => {
|
|
66
|
+
btn.addEventListener('click', (e) => {
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
const widgetName = btn.dataset.widget;
|
|
69
|
+
this.toggle(widgetName);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Bind close buttons inside widget headers
|
|
74
|
+
document.querySelectorAll('.widget-close-btn').forEach(closeBtn => {
|
|
75
|
+
closeBtn.addEventListener('click', (e) => {
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
const widgetContent = closeBtn.closest('.widget-content');
|
|
78
|
+
if (widgetContent) {
|
|
79
|
+
const widgetName = widgetContent.dataset.widget;
|
|
80
|
+
this.close(this.getSide(widgetName));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Close on outside click
|
|
86
|
+
document.addEventListener('click', (e) => {
|
|
87
|
+
// Close right-side widget if click is outside
|
|
88
|
+
if (this.activeRight && this.dropdownRight &&
|
|
89
|
+
!this.dropdownRight.contains(e.target) &&
|
|
90
|
+
!e.target.closest('.widget-button')) {
|
|
91
|
+
this.close('right');
|
|
92
|
+
}
|
|
93
|
+
// Close left-side widget if click is outside
|
|
94
|
+
if (this.activeLeft && this.dropdownLeft &&
|
|
95
|
+
!this.dropdownLeft.contains(e.target) &&
|
|
96
|
+
!e.target.closest('.widget-button')) {
|
|
97
|
+
this.close('left');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Close on Escape
|
|
102
|
+
document.addEventListener('keydown', (e) => {
|
|
103
|
+
if (e.key === 'Escape') {
|
|
104
|
+
if (this.activeRight) this.close('right');
|
|
105
|
+
if (this.activeLeft) this.close('left');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Initialize search widget content
|
|
110
|
+
this.initSearchWidget();
|
|
111
|
+
|
|
112
|
+
// Initialize recent activity widget
|
|
113
|
+
this.initRecentActivityWidget();
|
|
114
|
+
|
|
115
|
+
// Track current page view and initialize suggested content widget
|
|
116
|
+
this.trackPageView();
|
|
117
|
+
this.initSuggestedWidget();
|
|
118
|
+
|
|
119
|
+
// Restore saved widget states from localStorage
|
|
120
|
+
this.restoreState();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Save widget open/closed state to localStorage
|
|
125
|
+
*/
|
|
126
|
+
saveState(widgetName, isOpen) {
|
|
127
|
+
try {
|
|
128
|
+
const key = `ursa-widget-${widgetName}`;
|
|
129
|
+
localStorage.setItem(key, isOpen ? 'open' : 'closed');
|
|
130
|
+
} catch (e) { /* localStorage not available */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Restore widget states from localStorage.
|
|
135
|
+
* For widgets with no saved state, use their default (defaultOpen set).
|
|
136
|
+
*/
|
|
137
|
+
restoreState() {
|
|
138
|
+
// Gather all widget names
|
|
139
|
+
const widgetNames = new Set();
|
|
140
|
+
this.buttons.forEach(btn => widgetNames.add(btn.dataset.widget));
|
|
141
|
+
|
|
142
|
+
for (const widgetName of widgetNames) {
|
|
143
|
+
const key = `ursa-widget-${widgetName}`;
|
|
144
|
+
let saved;
|
|
145
|
+
try {
|
|
146
|
+
saved = localStorage.getItem(key);
|
|
147
|
+
} catch (e) { /* localStorage not available */ }
|
|
148
|
+
|
|
149
|
+
const shouldOpen = saved === 'open' || (saved === null && this.defaultOpen.has(widgetName));
|
|
150
|
+
if (shouldOpen) {
|
|
151
|
+
this.open(widgetName);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Toggle a widget open/closed.
|
|
158
|
+
*/
|
|
159
|
+
toggle(widgetName) {
|
|
160
|
+
const side = this.getSide(widgetName);
|
|
161
|
+
if (this.getActive(side) === widgetName) {
|
|
162
|
+
this.close(side);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.open(widgetName);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Open a specific widget panel.
|
|
171
|
+
*/
|
|
172
|
+
open(widgetName) {
|
|
173
|
+
const side = this.getSide(widgetName);
|
|
174
|
+
const dropdown = this.getDropdown(side);
|
|
175
|
+
if (!dropdown) return;
|
|
176
|
+
|
|
177
|
+
// Close any open widget on the same side first
|
|
178
|
+
const currentActive = this.getActive(side);
|
|
179
|
+
if (currentActive) {
|
|
180
|
+
this.deactivateContent(currentActive);
|
|
181
|
+
// Save the closed widget's state
|
|
182
|
+
this.saveState(currentActive, false);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.setActive(side, widgetName);
|
|
186
|
+
|
|
187
|
+
// Show dropdown
|
|
188
|
+
dropdown.classList.remove('hidden');
|
|
189
|
+
dropdown.dataset.activeWidget = widgetName;
|
|
190
|
+
|
|
191
|
+
// Show the correct content panel
|
|
192
|
+
this.activateContent(widgetName);
|
|
193
|
+
|
|
194
|
+
// Update button states (only for this side's buttons)
|
|
195
|
+
this.buttons.forEach(btn => {
|
|
196
|
+
if (this.getSide(btn.dataset.widget) === side) {
|
|
197
|
+
btn.classList.toggle('active', btn.dataset.widget === widgetName);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Save state
|
|
202
|
+
this.saveState(widgetName, true);
|
|
203
|
+
|
|
204
|
+
// Fire event for other scripts to listen to
|
|
205
|
+
document.dispatchEvent(new CustomEvent('widget-opened', { detail: { widget: widgetName, side } }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Close the currently open widget on a given side.
|
|
210
|
+
*/
|
|
211
|
+
close(side) {
|
|
212
|
+
const active = this.getActive(side);
|
|
213
|
+
if (!active) return;
|
|
214
|
+
|
|
215
|
+
const dropdown = this.getDropdown(side);
|
|
216
|
+
this.deactivateContent(active);
|
|
217
|
+
|
|
218
|
+
// Save state
|
|
219
|
+
this.saveState(active, false);
|
|
220
|
+
|
|
221
|
+
this.setActive(side, null);
|
|
222
|
+
if (dropdown) {
|
|
223
|
+
dropdown.classList.add('hidden');
|
|
224
|
+
delete dropdown.dataset.activeWidget;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Update button states for this side
|
|
228
|
+
this.buttons.forEach(btn => {
|
|
229
|
+
if (this.getSide(btn.dataset.widget) === side) {
|
|
230
|
+
btn.classList.remove('active');
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Fire event
|
|
235
|
+
document.dispatchEvent(new CustomEvent('widget-closed', { detail: { widget: active, side } }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Show a widget's content panel.
|
|
240
|
+
*/
|
|
241
|
+
activateContent(widgetName) {
|
|
242
|
+
const side = this.getSide(widgetName);
|
|
243
|
+
const dropdown = this.getDropdown(side);
|
|
244
|
+
if (!dropdown) return;
|
|
245
|
+
|
|
246
|
+
const content = dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
|
|
247
|
+
if (content) {
|
|
248
|
+
content.classList.add('active');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Widget-specific activation
|
|
252
|
+
if (widgetName === 'search') {
|
|
253
|
+
this.activateSearch();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Hide a widget's content panel.
|
|
259
|
+
*/
|
|
260
|
+
deactivateContent(widgetName) {
|
|
261
|
+
const side = this.getSide(widgetName);
|
|
262
|
+
const dropdown = this.getDropdown(side);
|
|
263
|
+
if (!dropdown) return;
|
|
264
|
+
|
|
265
|
+
const content = dropdown.querySelector(`.widget-content[data-widget="${widgetName}"]`);
|
|
266
|
+
if (content) {
|
|
267
|
+
content.classList.remove('active');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Widget-specific deactivation
|
|
271
|
+
if (widgetName === 'search') {
|
|
272
|
+
this.deactivateSearch();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Initialize search widget — move the search input and results into the widget panel.
|
|
278
|
+
*/
|
|
279
|
+
initSearchWidget() {
|
|
280
|
+
const searchContent = document.getElementById('widget-content-search');
|
|
281
|
+
if (!searchContent) return;
|
|
282
|
+
|
|
283
|
+
// The search input and wrapper are created by search.js (GlobalSearch).
|
|
284
|
+
// We need to wait for it to be ready, then move elements into the widget.
|
|
285
|
+
// Use a short delay to let GlobalSearch initialize first.
|
|
286
|
+
const moveSearch = () => {
|
|
287
|
+
const searchWrapper = document.querySelector('.search-wrapper-inline');
|
|
288
|
+
const searchResults = document.getElementById('search-results');
|
|
289
|
+
|
|
290
|
+
if (searchWrapper) {
|
|
291
|
+
// Clone the search input into the widget (the inline one stays for non-top-menu/mobile)
|
|
292
|
+
// Actually, we'll relocate the existing elements when the widget is activated.
|
|
293
|
+
// For now, create a dedicated search input for the widget.
|
|
294
|
+
const widgetInput = document.createElement('input');
|
|
295
|
+
widgetInput.id = 'widget-search-input';
|
|
296
|
+
widgetInput.type = 'text';
|
|
297
|
+
widgetInput.placeholder = 'Search...';
|
|
298
|
+
widgetInput.className = 'widget-search-input';
|
|
299
|
+
|
|
300
|
+
const widgetWrapper = document.createElement('div');
|
|
301
|
+
widgetWrapper.className = 'widget-search-wrapper';
|
|
302
|
+
widgetWrapper.appendChild(widgetInput);
|
|
303
|
+
|
|
304
|
+
// Create dedicated results container for widget
|
|
305
|
+
const widgetResults = document.createElement('div');
|
|
306
|
+
widgetResults.id = 'widget-search-results';
|
|
307
|
+
widgetResults.className = 'widget-search-results';
|
|
308
|
+
|
|
309
|
+
searchContent.appendChild(widgetWrapper);
|
|
310
|
+
searchContent.appendChild(widgetResults);
|
|
311
|
+
|
|
312
|
+
// Bind the widget search input to the GlobalSearch instance
|
|
313
|
+
this.bindWidgetSearch(widgetInput, widgetResults);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// Wait for search.js to initialize
|
|
318
|
+
setTimeout(moveSearch, 50);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Bind the widget search input to use GlobalSearch's search functionality.
|
|
323
|
+
*/
|
|
324
|
+
bindWidgetSearch(input, resultsContainer) {
|
|
325
|
+
this._widgetSearchInput = input;
|
|
326
|
+
this._widgetSearchResults = resultsContainer;
|
|
327
|
+
|
|
328
|
+
let currentSelection = -1;
|
|
329
|
+
|
|
330
|
+
input.addEventListener('input', () => {
|
|
331
|
+
const query = input.value.trim();
|
|
332
|
+
this.performWidgetSearch(query);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
input.addEventListener('keydown', (e) => {
|
|
336
|
+
const items = resultsContainer.querySelectorAll('.search-result-item');
|
|
337
|
+
|
|
338
|
+
switch (e.key) {
|
|
339
|
+
case 'ArrowDown':
|
|
340
|
+
e.preventDefault();
|
|
341
|
+
if (items.length > 0) {
|
|
342
|
+
currentSelection = Math.min(currentSelection + 1, items.length - 1);
|
|
343
|
+
this.updateWidgetSearchSelection(items, currentSelection);
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
case 'ArrowUp':
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
if (items.length > 0) {
|
|
349
|
+
currentSelection = Math.max(currentSelection - 1, 0);
|
|
350
|
+
this.updateWidgetSearchSelection(items, currentSelection);
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
case 'Enter':
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
if (currentSelection >= 0 && items[currentSelection]) {
|
|
356
|
+
items[currentSelection].click();
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
case 'Escape':
|
|
360
|
+
this.close();
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Reset selection on new search
|
|
366
|
+
input.addEventListener('input', () => { currentSelection = -1; });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Perform search using GlobalSearch's search logic, rendering into widget results.
|
|
371
|
+
*/
|
|
372
|
+
performWidgetSearch(query) {
|
|
373
|
+
const gs = window.globalSearch;
|
|
374
|
+
const container = this._widgetSearchResults;
|
|
375
|
+
if (!gs || !container) return;
|
|
376
|
+
|
|
377
|
+
container.innerHTML = '';
|
|
378
|
+
|
|
379
|
+
if (!query || query.length < gs.MIN_QUERY_LENGTH) {
|
|
380
|
+
if (query && query.length > 0) {
|
|
381
|
+
container.innerHTML = `<div class="search-result-message">Type at least ${gs.MIN_QUERY_LENGTH} characters to search</div>`;
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!gs.indexLoaded) {
|
|
387
|
+
container.innerHTML = '<div class="search-result-message">Loading search index...</div>';
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const pathResults = gs.searchPaths(query);
|
|
392
|
+
const fullTextResults = gs.searchFullText(query);
|
|
393
|
+
|
|
394
|
+
// Deduplicate
|
|
395
|
+
const pathPaths = new Set(pathResults.map(r => r.path));
|
|
396
|
+
const uniqueFullTextResults = fullTextResults.filter(r => !pathPaths.has(r.path));
|
|
397
|
+
|
|
398
|
+
if (pathResults.length === 0 && uniqueFullTextResults.length === 0) {
|
|
399
|
+
container.innerHTML = `<div class="search-result-message">No results for "${query}"</div>`;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Path results section
|
|
404
|
+
if (pathResults.length > 0) {
|
|
405
|
+
const section = document.createElement('div');
|
|
406
|
+
section.className = 'search-section';
|
|
407
|
+
const header = document.createElement('div');
|
|
408
|
+
header.className = 'search-section-header';
|
|
409
|
+
header.textContent = `Title/Path Matches (${pathResults.length})`;
|
|
410
|
+
section.appendChild(header);
|
|
411
|
+
|
|
412
|
+
const limit = Math.min(pathResults.length, 10);
|
|
413
|
+
for (let i = 0; i < limit; i++) {
|
|
414
|
+
section.appendChild(this.createWidgetResultItem(pathResults[i]));
|
|
415
|
+
}
|
|
416
|
+
if (pathResults.length > 10) {
|
|
417
|
+
const more = document.createElement('div');
|
|
418
|
+
more.className = 'search-result-message';
|
|
419
|
+
more.textContent = `... and ${pathResults.length - 10} more`;
|
|
420
|
+
section.appendChild(more);
|
|
421
|
+
}
|
|
422
|
+
container.appendChild(section);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Full-text results section
|
|
426
|
+
if (uniqueFullTextResults.length > 0) {
|
|
427
|
+
const section = document.createElement('div');
|
|
428
|
+
section.className = 'search-section';
|
|
429
|
+
const header = document.createElement('div');
|
|
430
|
+
header.className = 'search-section-header';
|
|
431
|
+
header.textContent = `Content Matches (${uniqueFullTextResults.length})`;
|
|
432
|
+
section.appendChild(header);
|
|
433
|
+
|
|
434
|
+
const limit = Math.min(uniqueFullTextResults.length, 10);
|
|
435
|
+
for (let i = 0; i < limit; i++) {
|
|
436
|
+
section.appendChild(this.createWidgetResultItem(uniqueFullTextResults[i]));
|
|
437
|
+
}
|
|
438
|
+
if (uniqueFullTextResults.length > 10) {
|
|
439
|
+
const more = document.createElement('div');
|
|
440
|
+
more.className = 'search-result-message';
|
|
441
|
+
more.textContent = `... and ${uniqueFullTextResults.length - 10} more`;
|
|
442
|
+
section.appendChild(more);
|
|
443
|
+
}
|
|
444
|
+
container.appendChild(section);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Create a search result item for the widget.
|
|
450
|
+
*/
|
|
451
|
+
createWidgetResultItem(result) {
|
|
452
|
+
const item = document.createElement('div');
|
|
453
|
+
item.className = 'search-result-item';
|
|
454
|
+
|
|
455
|
+
const title = document.createElement('div');
|
|
456
|
+
title.className = 'search-result-title';
|
|
457
|
+
title.textContent = result.title || 'Untitled';
|
|
458
|
+
|
|
459
|
+
const path = document.createElement('div');
|
|
460
|
+
path.className = 'search-result-path';
|
|
461
|
+
path.textContent = result.path || result.url || '';
|
|
462
|
+
|
|
463
|
+
item.appendChild(title);
|
|
464
|
+
item.appendChild(path);
|
|
465
|
+
|
|
466
|
+
item.addEventListener('click', () => {
|
|
467
|
+
window.location.href = result.url || result.path;
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
item.addEventListener('mouseenter', () => {
|
|
471
|
+
// Clear other selections
|
|
472
|
+
item.closest('.widget-search-results')?.querySelectorAll('.search-result-item').forEach(el => {
|
|
473
|
+
el.classList.remove('selected');
|
|
474
|
+
});
|
|
475
|
+
item.classList.add('selected');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
return item;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
updateWidgetSearchSelection(items, index) {
|
|
482
|
+
items.forEach((item, i) => {
|
|
483
|
+
item.classList.toggle('selected', i === index);
|
|
484
|
+
});
|
|
485
|
+
if (index >= 0 && items[index]) {
|
|
486
|
+
items[index].scrollIntoView({ block: 'nearest' });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Called when the search widget is activated.
|
|
492
|
+
*/
|
|
493
|
+
activateSearch() {
|
|
494
|
+
const input = this._widgetSearchInput;
|
|
495
|
+
if (input) {
|
|
496
|
+
// Focus with small delay to allow panel animation
|
|
497
|
+
setTimeout(() => input.focus(), 50);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Called when the search widget is deactivated.
|
|
503
|
+
*/
|
|
504
|
+
deactivateSearch() {
|
|
505
|
+
// Keep the search query so user can re-open and see results
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Initialize the Recent Activity widget — fetch data and render the list.
|
|
510
|
+
*/
|
|
511
|
+
initRecentActivityWidget() {
|
|
512
|
+
const container = document.querySelector('.recent-activity-list');
|
|
513
|
+
if (!container) return;
|
|
514
|
+
|
|
515
|
+
container.innerHTML = '<div class="recent-activity-loading">Loading...</div>';
|
|
516
|
+
|
|
517
|
+
fetch('/public/recent-activity.json')
|
|
518
|
+
.then(res => {
|
|
519
|
+
if (!res.ok) throw new Error('Not found');
|
|
520
|
+
return res.json();
|
|
521
|
+
})
|
|
522
|
+
.then(items => {
|
|
523
|
+
container.innerHTML = '';
|
|
524
|
+
if (!items || items.length === 0) {
|
|
525
|
+
container.innerHTML = '<div class="recent-activity-empty">No recent activity</div>';
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const ul = document.createElement('ul');
|
|
529
|
+
ul.className = 'recent-activity-items';
|
|
530
|
+
for (const item of items) {
|
|
531
|
+
const li = document.createElement('li');
|
|
532
|
+
li.className = 'recent-activity-item';
|
|
533
|
+
const a = document.createElement('a');
|
|
534
|
+
a.href = item.url;
|
|
535
|
+
a.textContent = item.title || 'Untitled';
|
|
536
|
+
a.className = 'recent-activity-link';
|
|
537
|
+
const time = document.createElement('span');
|
|
538
|
+
time.className = 'recent-activity-time';
|
|
539
|
+
time.textContent = this.formatRelativeTime(item.mtime);
|
|
540
|
+
time.title = new Date(item.mtime).toLocaleString();
|
|
541
|
+
li.appendChild(a);
|
|
542
|
+
li.appendChild(time);
|
|
543
|
+
ul.appendChild(li);
|
|
544
|
+
}
|
|
545
|
+
container.appendChild(ul);
|
|
546
|
+
})
|
|
547
|
+
.catch(() => {
|
|
548
|
+
container.innerHTML = '<div class="recent-activity-empty">Recent activity unavailable</div>';
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Format a timestamp into a human-readable relative time string.
|
|
554
|
+
*/
|
|
555
|
+
formatRelativeTime(mtimeMs) {
|
|
556
|
+
const now = Date.now();
|
|
557
|
+
const diff = now - mtimeMs;
|
|
558
|
+
const seconds = Math.floor(diff / 1000);
|
|
559
|
+
const minutes = Math.floor(seconds / 60);
|
|
560
|
+
const hours = Math.floor(minutes / 60);
|
|
561
|
+
const days = Math.floor(hours / 24);
|
|
562
|
+
const weeks = Math.floor(days / 7);
|
|
563
|
+
const months = Math.floor(days / 30);
|
|
564
|
+
const years = Math.floor(days / 365);
|
|
565
|
+
|
|
566
|
+
if (seconds < 60) return 'just now';
|
|
567
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
568
|
+
if (hours < 24) return `${hours}h ago`;
|
|
569
|
+
if (days < 7) return `${days}d ago`;
|
|
570
|
+
if (weeks < 5) return `${weeks}w ago`;
|
|
571
|
+
if (months < 12) return `${months}mo ago`;
|
|
572
|
+
return `${years}y ago`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Track current page view in localStorage.
|
|
577
|
+
* Stores a map of URL → { count, lastVisit, title }
|
|
578
|
+
*/
|
|
579
|
+
trackPageView() {
|
|
580
|
+
const url = window.location.pathname;
|
|
581
|
+
// Skip tracking for index/home pages to keep suggestions more focused
|
|
582
|
+
if (url === '/' || url === '/index.html') return;
|
|
583
|
+
|
|
584
|
+
const STORAGE_KEY = 'ursa-page-views';
|
|
585
|
+
const MAX_TRACKED_PAGES = 100; // Limit storage size
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
let pageViews = {};
|
|
589
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
590
|
+
if (stored) {
|
|
591
|
+
pageViews = JSON.parse(stored);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Get page title from the document
|
|
595
|
+
const title = document.title || url;
|
|
596
|
+
|
|
597
|
+
// Update or create entry for this page
|
|
598
|
+
if (pageViews[url]) {
|
|
599
|
+
pageViews[url].count += 1;
|
|
600
|
+
pageViews[url].lastVisit = Date.now();
|
|
601
|
+
pageViews[url].title = title;
|
|
602
|
+
} else {
|
|
603
|
+
pageViews[url] = {
|
|
604
|
+
count: 1,
|
|
605
|
+
lastVisit: Date.now(),
|
|
606
|
+
title: title
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Prune oldest entries if we exceed the limit
|
|
611
|
+
const entries = Object.entries(pageViews);
|
|
612
|
+
if (entries.length > MAX_TRACKED_PAGES) {
|
|
613
|
+
// Sort by lastVisit and keep only the most recent
|
|
614
|
+
entries.sort((a, b) => b[1].lastVisit - a[1].lastVisit);
|
|
615
|
+
pageViews = Object.fromEntries(entries.slice(0, MAX_TRACKED_PAGES));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(pageViews));
|
|
619
|
+
} catch (e) {
|
|
620
|
+
// localStorage not available or quota exceeded
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Initialize the Suggested Content widget.
|
|
626
|
+
* Shows frequently viewed pages based on localStorage tracking.
|
|
627
|
+
*/
|
|
628
|
+
initSuggestedWidget() {
|
|
629
|
+
const container = document.querySelector('.suggested-content-list');
|
|
630
|
+
if (!container) return;
|
|
631
|
+
|
|
632
|
+
const STORAGE_KEY = 'ursa-page-views';
|
|
633
|
+
const MAX_SUGGESTIONS = 10;
|
|
634
|
+
const currentUrl = window.location.pathname;
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
638
|
+
if (!stored) {
|
|
639
|
+
container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const pageViews = JSON.parse(stored);
|
|
644
|
+
const entries = Object.entries(pageViews);
|
|
645
|
+
|
|
646
|
+
if (entries.length === 0) {
|
|
647
|
+
container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Filter out current page and sort by view count (descending)
|
|
652
|
+
const sorted = entries
|
|
653
|
+
.filter(([url]) => url !== currentUrl)
|
|
654
|
+
.sort((a, b) => {
|
|
655
|
+
// Primary sort: view count (descending)
|
|
656
|
+
const countDiff = b[1].count - a[1].count;
|
|
657
|
+
if (countDiff !== 0) return countDiff;
|
|
658
|
+
// Secondary sort: last visit (descending)
|
|
659
|
+
return b[1].lastVisit - a[1].lastVisit;
|
|
660
|
+
})
|
|
661
|
+
.slice(0, MAX_SUGGESTIONS);
|
|
662
|
+
|
|
663
|
+
if (sorted.length === 0) {
|
|
664
|
+
container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
container.innerHTML = '';
|
|
669
|
+
const ul = document.createElement('ul');
|
|
670
|
+
ul.className = 'suggested-items';
|
|
671
|
+
|
|
672
|
+
for (const [url, data] of sorted) {
|
|
673
|
+
const li = document.createElement('li');
|
|
674
|
+
li.className = 'suggested-item';
|
|
675
|
+
|
|
676
|
+
const a = document.createElement('a');
|
|
677
|
+
a.href = url;
|
|
678
|
+
a.className = 'suggested-link';
|
|
679
|
+
a.textContent = data.title || url;
|
|
680
|
+
|
|
681
|
+
const meta = document.createElement('span');
|
|
682
|
+
meta.className = 'suggested-meta';
|
|
683
|
+
meta.textContent = `${data.count} view${data.count !== 1 ? 's' : ''}`;
|
|
684
|
+
meta.title = `Last visited: ${new Date(data.lastVisit).toLocaleString()}`;
|
|
685
|
+
|
|
686
|
+
li.appendChild(a);
|
|
687
|
+
li.appendChild(meta);
|
|
688
|
+
ul.appendChild(li);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
container.appendChild(ul);
|
|
692
|
+
} catch (e) {
|
|
693
|
+
container.innerHTML = '<div class="suggested-empty">Unable to load suggestions</div>';
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Initialize widgets when DOM is ready
|
|
699
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
700
|
+
window.widgetManager = new WidgetManager();
|
|
701
|
+
});
|
package/package.json
CHANGED