thatcher 1.0.4 → 1.0.6

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.
Files changed (62) hide show
  1. package/package.json +18 -1
  2. package/src/app/api/debug/[[...path]]/route.js +217 -0
  3. package/src/app/api/formula/[[...path]]/route.js +142 -0
  4. package/src/cli.js +0 -1
  5. package/src/config/spec-helpers.js +6 -0
  6. package/src/engine.server.js +2 -2
  7. package/src/index.js +21 -11
  8. package/src/lib/auth-middleware.js +4 -2
  9. package/src/lib/busybase-adapter.js +162 -0
  10. package/src/lib/config-field-helpers.js +1 -1
  11. package/src/lib/config-generator-engine.js +24 -0
  12. package/src/lib/crud-handlers.js +21 -1
  13. package/src/lib/database-core.js +4 -7
  14. package/src/lib/database-migrations.js +254 -0
  15. package/src/lib/date-utils.js +164 -0
  16. package/src/lib/debug-registry.js +165 -0
  17. package/src/lib/error-handler.js +78 -0
  18. package/src/lib/export-sink.js +226 -0
  19. package/src/lib/field-iterator.js +126 -0
  20. package/src/lib/hyperformula-service.js +304 -0
  21. package/src/lib/metrics-collector.js +196 -0
  22. package/src/lib/observability-bootstrap.js +121 -0
  23. package/src/lib/perf-profiler.js +238 -0
  24. package/src/lib/query-engine.js +47 -0
  25. package/src/lib/query-string-adapter.js +97 -2
  26. package/src/lib/request-tracing.js +216 -0
  27. package/src/lib/response-formatter.js +0 -2
  28. package/src/lib/route-resolver.js +10 -0
  29. package/src/lib/status-helpers.js +128 -0
  30. package/src/lib/tracing.js +287 -0
  31. package/src/lib/utils.js +81 -0
  32. package/src/lib/validate.js +124 -1
  33. package/src/lib/xstate-workflow-engine.js +478 -0
  34. package/src/plugins/index.js +27 -0
  35. package/src/server/server.js +23 -4
  36. package/src/services/permission.service.js +6 -2
  37. package/src/ui/advanced-search-renderer.js +3 -3
  38. package/src/ui/advanced-widgets.js +4 -4
  39. package/src/ui/common-handlers.js +4 -2
  40. package/src/ui/dashboard-renderer.js +4 -4
  41. package/src/ui/engagement-cards.js +10 -10
  42. package/src/ui/engagement-grid-renderer.js +11 -8
  43. package/src/ui/entity-renderer.js +4 -2
  44. package/src/ui/event-delegation.js +348 -8
  45. package/src/ui/file-dialogs.js +9 -9
  46. package/src/ui/format-helpers.js +37 -3
  47. package/src/ui/highlight-threading-renderer.js +9 -9
  48. package/src/ui/job-management-renderer.js +6 -6
  49. package/src/ui/monitoring-dashboard-client.js +17 -3
  50. package/src/ui/notifications-renderer.js +6 -7
  51. package/src/ui/page-handler.js +9 -0
  52. package/src/ui/render-helpers.js +8 -1
  53. package/src/ui/review-comparison-renderer.js +1 -1
  54. package/src/ui/review-detail-renderer.js +6 -6
  55. package/src/ui/review-mwr-renderer.js +13 -10
  56. package/src/ui/review-widgets.js +5 -5
  57. package/src/ui/rfi-detail-renderer.js +9 -9
  58. package/src/ui/settings-renderer-advanced.js +13 -13
  59. package/src/ui/settings-renderer-teams.js +10 -9
  60. package/src/ui/settings-renderer.js +25 -24
  61. package/src/ui/spacing-system.js +8 -4
  62. package/src/ui/widgets.js +2 -2
@@ -1,7 +1,7 @@
1
1
  import { statusLabel } from '@/ui/renderer.js';
2
2
 
3
3
  export function zipFileCreationDialog(engagementId) {
4
- return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="zip-create-dialog-title" aria-hidden="true">
4
+ return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close="zip-create-dialog" role="dialog" aria-modal="true" aria-labelledby="zip-create-dialog-title" aria-hidden="true">
5
5
  <div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="zip-create-dialog-title">Create Zip Archive</span><button class="dialog-close" data-dialog-close="zip-create-dialog" aria-label="Close dialog">&times;</button></div>
6
6
  <div class="dialog-body"><div id="zcd-files" class="flex flex-col gap-2" style="max-height:300px;overflow:auto"></div><div class="flex items-center gap-2 mt-3"><label class="flex items-center gap-2 text-sm"><input type="checkbox" id="zcd-all" class="checkbox checkbox-sm" checked/><span>Select All</span></label></div></div>
7
7
  <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="zip-create-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="zcdCreate">Create Zip</button></div>
@@ -13,7 +13,7 @@ export function zipFileCreationDialog(engagementId) {
13
13
  </script>`;
14
14
  }
15
15
  export function crossEngagementFilePicker(currentEngId) {
16
- return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="cross-file-picker-title" aria-hidden="true">
16
+ return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close="cross-file-picker" role="dialog" aria-modal="true" aria-labelledby="cross-file-picker-title" aria-hidden="true">
17
17
  <div class="dialog-panel" style="max-width:640px"><div class="dialog-header"><span class="dialog-title" id="cross-file-picker-title">Pick File from Another Engagement</span><button class="dialog-close" data-dialog-close="cross-file-picker" aria-label="Close dialog">&times;</button></div>
18
18
  <div class="dialog-body"><div class="modal-form-group"><label for="cfp-eng">Engagement</label><select id="cfp-eng" class="select select-bordered w-full" onchange="cfpLoadFiles()"></select></div><div id="cfp-files" class="flex flex-col gap-2 mt-3" style="max-height:300px;overflow:auto"></div></div>
19
19
  <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="cross-file-picker">Close</button></div>
@@ -37,13 +37,13 @@ export function userCvUpload(userId) {
37
37
  </script>`;
38
38
  }
39
39
  export function quickViewAttachment() {
40
- return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="quick-view-title" aria-hidden="true">
40
+ return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close="quick-view" role="dialog" aria-modal="true" aria-labelledby="quick-view-title" aria-hidden="true">
41
41
  <div class="dialog-panel" style="max-width:900px;max-height:90vh"><div class="dialog-header"><span class="dialog-title" id="quick-view-title">Preview</span><button class="dialog-close" data-dialog-close="quick-view" aria-label="Close dialog">&times;</button></div>
42
42
  <div class="dialog-body" style="overflow:auto;max-height:75vh"><div id="qv-content" class="text-center"><div class="text-gray-500">Loading...</div></div></div>
43
43
  <div class="dialog-footer"><a id="qv-download" href="#" download class="btn btn-primary btn-sm">Download</a><button class="btn btn-ghost btn-sm" data-dialog-close="quick-view">Close</button></div>
44
44
  </div></div>
45
45
  <script>
46
- window.quickView=function(url,name,type){document.getElementById('quick-view').style.display='flex';document.getElementById('quick-view-title').textContent=name||'Preview';document.getElementById('qv-download').href=url;var c=document.getElementById('qv-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" alt="'+(name||'File preview')+'" style="max-width:100%;max-height:70vh"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:70vh;border:none"></iframe>'}else{c.innerHTML='<div class="py-8 text-gray-500"><div style="font-size:3rem">&#128196;</div><div class="mt-2">'+name+'</div><div class="text-xs mt-1">Preview not available</div></div>'}};
46
+ window.quickView=function(url,name,type){document.getElementById('quick-view').style.display='flex';document.getElementById('quick-view-title').textContent=name||'Preview';document.getElementById('qv-download').href=url;var c=document.getElementById('qv-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" alt="'+(name||'File preview')+'" style="max-width:100%;max-height:70vh"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:70vh;border:none"></iframe>'}else{c.innerHTML='<div class="py-8 text-gray-500"><div style="display:flex;justify-content:center"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg></div><div class="mt-2">'+name+'</div><div class="text-xs mt-1">Preview not available</div></div>'}};
47
47
  </script>`;
48
48
  }
49
49
  export function fetchCachedPdf(fileId) {
@@ -53,24 +53,24 @@ export function fetchCachedPdf(fileId) {
53
53
  }
54
54
  export function fileAttachmentBar(files = []) {
55
55
  if (!files.length) return '';
56
- const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${f.id}/download","${(f.name || '').replace(/"/g, '&quot;')}","${f.mime_type || ''}"]'><span style="font-size:1.2rem">&#128206;</span><span class="text-xs truncate" style="max-width:120px">${f.name || 'file'}</span></div>`).join('');
56
+ const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${f.id}/download","${(f.name || '').replace(/"/g, '&quot;')}","${f.mime_type || ''}"]'><span style="display:inline-flex"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span><span class="text-xs truncate" style="max-width:120px">${f.name || 'file'}</span></div>`).join('');
57
57
  return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
58
58
  }
59
59
  export function fileLinksBar(links = []) {
60
60
  if (!links.length) return '';
61
- const items = links.map(l => `<a href="${l.url || '#'}" target="_blank" class="flex items-center gap-1 p-1 rounded hover:bg-gray-100 text-xs text-blue-600"><span>&#128279;</span><span class="truncate" style="max-width:150px">${l.name || l.url || 'Link'}</span></a>`).join('');
61
+ const items = links.map(l => `<a href="${l.url || '#'}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1 p-1 rounded hover:bg-gray-100 text-xs text-blue-600"><span style="display:inline-flex"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span><span class="truncate" style="max-width:150px">${l.name || l.url || 'Link'}</span></a>`).join('');
62
62
  return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
63
63
  }
64
64
  export function reviewAttachmentChoiceDialog(reviewId) {
65
- return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="rev-attach-choice-title" aria-hidden="true">
65
+ return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close="rev-attach-choice" role="dialog" aria-modal="true" aria-labelledby="rev-attach-choice-title" aria-hidden="true">
66
66
  <div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rev-attach-choice-title">Add Attachment</span><button class="dialog-close" data-dialog-close="rev-attach-choice" aria-label="Close dialog">&times;</button></div>
67
- <div class="dialog-body"><div class="flex flex-col gap-3"><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="triggerUpload">&#128206; Upload New File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="openCrossFilePicker">&#128279; Link Existing File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="racUrl">&#127760; Add URL</button></div><input type="file" id="rac-upload" style="display:none" onchange="racUploadFile()"/></div>
67
+ <div class="dialog-body"><div class="flex flex-col gap-3"><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="triggerUpload"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>Upload New File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="openCrossFilePicker"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>Link Existing File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="racUrl"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>Add URL</button></div><input type="file" id="rac-upload" style="display:none" onchange="racUploadFile()"/></div>
68
68
  <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rev-attach-choice">Cancel</button></div>
69
69
  </div></div>
70
70
  <script>
71
71
  window.openRevAttachChoice=function(){document.getElementById('rev-attach-choice').style.display='flex'};
72
72
  window.racUploadFile=async function(){var file=document.getElementById('rac-upload').files[0];if(!file)return;var fd=new FormData();fd.append('file',file);fd.append('review_id','${reviewId}');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)showToast('Attached','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}};
73
- window.racUrl=function(){var url=prompt('Enter URL:');if(!url)return;fetch('/api/file',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:'${reviewId}',url:url,name:url,type:'link'})}).then(function(r){if(r.ok)showToast('Link added','success');else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
73
+ window.racUrl=async function(){var url=await window.gmPrompt({title:'Add Link','label':'Enter URL','placeholder':'https://...','type':'url',confirmLabel:'Add Link',validate:function(v){return /^https?:\\/\\//i.test(v)?null:'Enter a valid http(s) URL.'}});if(!url)return;fetch('/api/file',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:'${reviewId}',url:url,name:url,type:'link'})}).then(function(r){if(r.ok)showToast('Link added','success');else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
74
74
  </script>`;
75
75
  }
76
76
  export function uploadFridayFilesCrossApp(engagementId) {
@@ -89,10 +89,44 @@ export function truncate(str, n) {
89
89
  return s.length > max ? s.slice(0, max) + '…' : s;
90
90
  }
91
91
 
92
- export function emptyState(message, icon) {
93
- const ico = icon || '📭';
92
+ // Shared stroke-based SVG icon set. Replaces emoji/raw-unicode glyphs with a
93
+ // consistent, theme-following (currentColor) icon system. Add new paths here
94
+ // rather than scattering inline <svg> across renderers.
95
+ const ICON_PATHS = {
96
+ folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
97
+ list: '<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>',
98
+ user: '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
99
+ document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
100
+ inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
101
+ mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/>',
102
+ gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
103
+ archive: '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/>',
104
+ database: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
105
+ check: '<path d="M20 6 9 17l-5-5"/>',
106
+ chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
107
+ clipboard: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
108
+ flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
109
+ star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
110
+ tag: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
111
+ users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>',
112
+ bell: '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
113
+ link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
114
+ building: '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4M8 6h.01M16 6h.01M12 6h.01M8 10h.01M16 10h.01M12 10h.01M8 14h.01M16 14h.01M12 14h.01"/>',
115
+ lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
116
+ undo: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
117
+ external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
118
+ };
119
+
120
+ export function icon(name, size = 18) {
121
+ const path = ICON_PATHS[name];
122
+ if (!path) return '';
123
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${path}</svg>`;
124
+ }
125
+
126
+ export function emptyState(message, iconName) {
127
+ const ico = typeof iconName === 'string' && ICON_PATHS[iconName] ? icon(iconName, 32) : icon('inbox', 32);
94
128
  return `<div style="text-align:center;padding:48px 24px;color:var(--color-text-muted,#aaa)">
95
- <div style="font-size:2rem;margin-bottom:12px">${ico}</div>
129
+ <div style="display:flex;justify-content:center;margin-bottom:12px">${ico}</div>
96
130
  <div style="font-size:0.88rem;font-weight:500">${message || 'No items found'}</div>
97
131
  </div>`;
98
132
  }
@@ -131,7 +131,7 @@ export function renderHighlightThreading(user, review, highlights, responseMap)
131
131
  </div>`;
132
132
 
133
133
  const threadScript = `
134
- window.showToast=window.showToast||function(m,t){alert(m)};
134
+ if(!window.showToast)window.showToast=function(m,t){var c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';c.setAttribute('role','status');c.setAttribute('aria-live','polite');c.setAttribute('aria-atomic','true');document.body.appendChild(c)}var d=document.createElement('div');d.className='toast toast-'+(t||'info');d.textContent=m;c.appendChild(d);setTimeout(function(){d.style.opacity='0';setTimeout(function(){d.remove()},300)},3000)};
135
135
  window.filterThreads=function(f){
136
136
  document.querySelectorAll('[data-thread-filter]').forEach(b=>{
137
137
  b.className=b.dataset.threadFilter===f?'pill pill-primary':'pill pill-neutral';
@@ -146,12 +146,12 @@ export function renderHighlightThreading(user, review, highlights, responseMap)
146
146
  };
147
147
  window.submitResponse=async function(hid){
148
148
  const ta=document.getElementById('reply-'+hid);
149
- if(!ta||!ta.value.trim()){alert('Please enter a response');return}
149
+ if(!ta||!ta.value.trim()){showToast('Please enter a response','error');return}
150
150
  try{
151
151
  const res=await fetch('/api/mwr/review/highlight/'+hid+'/responses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:ta.value.trim()})});
152
152
  if(res.ok){ta.value='';showToast('Response submitted','success');setTimeout(()=>location.reload(),500)}
153
- else{const err=await res.json().then(d=>d.error||d.message||'Failed').catch(()=>'Failed');alert('Error: '+err)}
154
- }catch(e){alert('Error: '+e.message)}
153
+ else{const err=await res.json().then(d=>d.error||d.message||'Failed').catch(()=>'Failed');showToast('Error: '+err,'error')}
154
+ }catch(e){showToast('Error: '+e.message,'error')}
155
155
  };
156
156
  window.updateResolution=async function(hid){
157
157
  const sel=document.getElementById('resolve-status-'+hid);
@@ -159,16 +159,16 @@ export function renderHighlightThreading(user, review, highlights, responseMap)
159
159
  try{
160
160
  const res=await fetch('/api/mwr/review/highlight/'+hid,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:sel.value})});
161
161
  if(res.ok){showToast('Status updated','success');setTimeout(()=>location.reload(),500)}
162
- else{alert('Failed to update')}
163
- }catch(e){alert('Error: '+e.message)}
162
+ else{showToast('Failed to update','error')}
163
+ }catch(e){showToast('Error: '+e.message,'error')}
164
164
  };
165
165
  window.bulkResolve=async function(){
166
- if(!confirm('Resolve all highlights in this review?'))return;
166
+ if(!(await window.gmConfirm({title:'Please confirm',message:'Resolve all highlights in this review?',danger:false,confirmLabel:'OK'})))return;
167
167
  try{
168
168
  const res=await fetch('/api/mwr/review/${review.id}?action=resolve-all',{method:'POST'});
169
169
  if(res.ok){showToast('All highlights resolved','success');setTimeout(()=>location.reload(),500)}
170
- else{alert('Failed to resolve all')}
171
- }catch(e){alert('Error: '+e.message)}
170
+ else{showToast('Failed to resolve all','error')}
171
+ }catch(e){showToast('Error: '+e.message,'error')}
172
172
  };`;
173
173
 
174
174
  const bc = [{ href: '/', label: 'Home' }, { href: '/review', label: 'Reviews' }, { href: `/review/${review.id}`, label: review.name || 'Review' }, { label: 'Highlights' }];
@@ -11,11 +11,11 @@ function fmtDate(ts) {
11
11
 
12
12
  function jobStatusBadge(status) {
13
13
  const colors = {
14
- running: { bg: '#dbeafe', text: '#1e40af', icon: '&#9654;' },
15
- success: { bg: '#d1fae5', text: '#065f46', icon: '&#10003;' },
16
- failed: { bg: '#fee2e2', text: '#991b1b', icon: '&#10007;' },
17
- scheduled: { bg: '#fef3c7', text: '#92400e', icon: '&#9200;' },
18
- disabled: { bg: '#f3f4f6', text: '#6b7280', icon: '&#9679;' },
14
+ running: { bg: '#dbeafe', text: '#1e40af', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>` },
15
+ success: { bg: '#d1fae5', text: '#065f46', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>' },
16
+ failed: { bg: '#fee2e2', text: '#991b1b', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>` },
17
+ scheduled: { bg: '#fef3c7', text: '#92400e', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>` },
18
+ disabled: { bg: '#f3f4f6', text: '#6b7280', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>` },
19
19
  };
20
20
  const c = colors[status] || colors.scheduled;
21
21
  return `<span style="background:${c.bg};color:${c.text};padding:2px 10px;border-radius:9999px;font-size:0.75rem;font-weight:500">${c.icon} ${(status || 'unknown').replace(/_/g, ' ')}</span>`;
@@ -55,7 +55,7 @@ export function renderJobManagement(user, jobs, recentLogs = []) {
55
55
 
56
56
  const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Scheduled Jobs</h1>${canManage ? '<button class="btn btn-primary btn-sm" data-action="triggerAll">Run All Now</button>' : ''}</div>${statCards}${table}${logTable}`;
57
57
 
58
- const jobScript = `window.triggerJob=async function(name){try{const r=await fetch('/api/admin/jobs/'+name+'/trigger',{method:'POST'});if(r.ok){location.reload()}else{alert('Trigger failed: '+(await r.text()))}}catch(e){alert(e.message)}};window.toggleJob=async function(name,enabled){try{const r=await fetch('/api/admin/jobs/'+name,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});if(r.ok)location.reload();else alert('Toggle failed')}catch(e){alert(e.message)}};window.triggerAll=async function(){if(!confirm('Run all jobs now?'))return;try{const r=await fetch('/api/admin/jobs/trigger-all',{method:'POST'});if(r.ok)location.reload();else alert('Failed')}catch(e){alert(e.message)}}`;
58
+ const jobScript = `window.triggerJob=async function(name){try{const r=await fetch('/api/admin/jobs/'+name+'/trigger',{method:'POST'});if(r.ok){location.reload()}else{showToast('Trigger failed: '+(await r.text()),'error')}}catch(e){showToast(e.message,'error')}};window.toggleJob=async function(name,enabled){try{const r=await fetch('/api/admin/jobs/'+name,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});if(r.ok)location.reload();else showToast('Toggle failed','error')}catch(e){showToast(e.message,'error')}};window.triggerAll=async function(){if(!(await window.gmConfirm({title:'Please confirm',message:'Run all jobs now?',danger:false,confirmLabel:'OK'})))return;try{const r=await fetch('/api/admin/jobs/trigger-all',{method:'POST'});if(r.ok)location.reload();else showToast('Failed','error')}catch(e){showToast(e.message,'error')}}`;
59
59
 
60
60
  return page(user, 'Scheduled Jobs | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Jobs' }], content, [jobScript]);
61
61
  }
@@ -5,13 +5,27 @@ export function monitoringDashboardScript() {
5
5
 
6
6
  function showTab(name) {
7
7
  document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'))
8
- document.getElementById('tab-' + name).classList.remove('hidden')
8
+ var panel = document.getElementById('tab-' + name)
9
+ if (panel) panel.classList.remove('hidden')
9
10
 
10
- document.querySelectorAll('.tab').forEach(el => el.classList.remove('tab-active'))
11
- event.target.classList.add('tab-active')
11
+ document.querySelectorAll('.tab').forEach(el => {
12
+ el.classList.remove('tab-active')
13
+ var args = el.getAttribute('data-args')
14
+ if (args && args.indexOf('"' + name + '"') >= 0) el.classList.add('tab-active')
15
+ })
12
16
 
13
17
  currentTab = name
14
18
  }
19
+ window.showTab = showTab
20
+
21
+ // This standalone page does not load the shared event-delegation script,
22
+ // so wire the data-action="showTab" tab buttons directly.
23
+ document.addEventListener('click', function (e) {
24
+ var btn = e.target.closest('[data-action="showTab"]')
25
+ if (!btn) return
26
+ e.preventDefault()
27
+ try { showTab(JSON.parse(btn.getAttribute('data-args') || '[]')[0]) } catch (_) {}
28
+ })
15
29
 
16
30
  async function fetchMetrics() {
17
31
  try {
@@ -1,5 +1,5 @@
1
1
  import { page } from '@/ui/layout.js';
2
- import { esc, TOAST_SCRIPT } from '@/ui/render-helpers.js';
2
+ import { esc, TOAST_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
3
3
 
4
4
  function fmtDate(ts) {
5
5
  if (!ts) return '-';
@@ -18,18 +18,17 @@ function notifRow(n) {
18
18
  <td class="text-xs"><span class="badge badge-flat-secondary">${esc(typeLabel)}</span></td>
19
19
  <td class="text-sm max-w-md">${esc(n.message || '-')}</td>
20
20
  <td>${entityLink}</td>
21
- <td>${isRead ? '<span class="text-xs text-base-content/30">Read</span>' : `<button onclick="markRead('${esc(n.id)}')" class="btn btn-ghost btn-xs">Mark read</button>`}</td>
21
+ <td>${isRead ? '<span class="text-xs text-base-content/30">Read</span>' : `<button data-action="markRead" data-args='["${esc(n.id)}"]' class="btn btn-ghost btn-xs">Mark read</button>`}</td>
22
22
  </tr>`;
23
23
  }
24
24
 
25
25
  export function renderNotificationsPage(user, notifs = []) {
26
- const rows = notifs.map(notifRow).join('') ||
27
- `<tr><td colspan="5" class="text-center py-8 text-base-content/40 text-sm">No notifications</td></tr>`;
26
+ const rows = notifs.map(notifRow).join('') || emptyRow(5, 'No notifications');
28
27
 
29
28
  const content = `
30
29
  <div class="page-header">
31
30
  <h1 class="page-title">Notifications</h1>
32
- <button onclick="markAllRead()" class="btn btn-ghost btn-sm">Mark All Read</button>
31
+ <button data-action="markAllRead" class="btn btn-ghost btn-sm">Mark All Read</button>
33
32
  </div>
34
33
  <div class="card-clean">
35
34
  <div class="card-clean-body">
@@ -44,8 +43,8 @@ export function renderNotificationsPage(user, notifs = []) {
44
43
  `;
45
44
 
46
45
  const script = `${TOAST_SCRIPT}
47
- async function markRead(id){try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({id})});var row=document.querySelector('[data-notif-id="'+id+'"]');if(row){row.classList.add('opacity-60');var btn=row.querySelector('button');if(btn)btn.replaceWith(document.createTextNode('Read'))}var count=document.getElementById('notif-count');if(count){var c=parseInt(count.textContent||'0',10)-1;if(c<=0)count.style.display='none';else count.textContent=c}}catch(e){showToast('Error','error')}}
48
- async function markAllRead(){try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({})});document.querySelectorAll('[data-notif-id]').forEach(function(row){row.classList.add('opacity-60');var btn=row.querySelector('button');if(btn)btn.replaceWith(document.createTextNode('Read'))});var count=document.getElementById('notif-count');if(count)count.style.display='none';showToast('All marked read','success')}catch(e){showToast('Error','error')}}`;
46
+ window.markRead=async function(id){try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({id})});var row=document.querySelector('[data-notif-id="'+id+'"]');if(row){row.classList.add('opacity-60');var btn=row.querySelector('button');if(btn)btn.replaceWith(document.createTextNode('Read'))}var count=document.getElementById('notif-count');if(count){var c=parseInt(count.textContent||'0',10)-1;if(c<=0)count.style.display='none';else count.textContent=c}}catch(e){showToast('Error','error')}};
47
+ window.markAllRead=async function(){try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({})});document.querySelectorAll('[data-notif-id]').forEach(function(row){row.classList.add('opacity-60');var btn=row.querySelector('button');if(btn)btn.replaceWith(document.createTextNode('Read'))});var count=document.getElementById('notif-count');if(count)count.style.display='none';showToast('All marked read','success')}catch(e){showToast('Error','error')}};`;
49
48
 
50
49
  return page(user, 'Notifications | Moonlanding', null, content, [script]);
51
50
  }
@@ -141,6 +141,15 @@ export async function handlePage(pathname, req, res) {
141
141
 
142
142
  if (normalized === '/engagements') return handleEngagementList(user, req);
143
143
  if (normalized === '/search') return handleSearch(user, req);
144
+ if (normalized === '/monitoring') {
145
+ if (!isPartner(user)) return renderAccessDenied(user, 'monitoring', 'view');
146
+ const { renderMonitoringDashboard } = await lazyRenderer('monitoring-dashboard.js');
147
+ return renderMonitoringDashboard();
148
+ }
149
+ if (normalized === '/flexup') {
150
+ const { renderFlexUpView } = await lazyRenderer('flexup-view-renderer.js');
151
+ return renderFlexUpView(user);
152
+ }
144
153
  if (normalized === '/ml-console') {
145
154
  const db = getDatabase();
146
155
  let candidates = []; let reviewMap = {};
@@ -47,12 +47,18 @@ export const AVATAR_SIZES = { sm: 24, md: 32, lg: 40, xl: 48 }
47
47
 
48
48
  export const TOAST_SCRIPT = `window.showToast=(m,t='info')=>{let c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';c.setAttribute('role','status');c.setAttribute('aria-live','polite');c.setAttribute('aria-atomic','true');document.body.appendChild(c)}const d=document.createElement('div');d.className='toast toast-'+t;d.textContent=m;c.appendChild(d);setTimeout(()=>{d.style.opacity='0';setTimeout(()=>d.remove(),300)},3000)};`
49
49
 
50
- export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col)th.classList.add(sortDir===1?'sort-asc':'sort-desc')});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>th.addEventListener('click',()=>sortTable(th.dataset.sort)))})})();`
50
+ export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col){th.classList.add(sortDir===1?'sort-asc':'sort-desc');th.setAttribute('aria-sort',sortDir===1?'ascending':'descending')}else{th.removeAttribute('aria-sort')}});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>{th.setAttribute('tabindex','0');th.setAttribute('role','button');if(!th.getAttribute('aria-label'))th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim());th.addEventListener('click',()=>sortTable(th.dataset.sort));th.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '||e.key==='Spacebar'){e.preventDefault();sortTable(th.dataset.sort)}})})})})();`
51
51
 
52
52
  export function esc(s) {
53
53
  return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
54
54
  }
55
55
 
56
+ // Canonical empty-row for a <tbody>. One styling for every "no rows" message so
57
+ // empty tables read as intentional and consistent (was ~10 divergent inline styles).
58
+ export function emptyRow(colspan, message = 'No items found') {
59
+ return `<tr><td colspan="${colspan}" class="table-empty-row">${message}</td></tr>`
60
+ }
61
+
56
62
  export function stagePill(stage) {
57
63
  const cfg = STAGE_CONFIG.find(s => s.key === stage)
58
64
  const lbl = cfg ? cfg.label : (stage || '-')
@@ -115,6 +121,7 @@ export function nameHash(name) {
115
121
  }
116
122
 
117
123
  export { getInitials } from '@/lib/utils.js'
124
+ export { icon } from '@/ui/format-helpers.js'
118
125
 
119
126
  export function fmtDate(ts) {
120
127
  if (!ts) return '-';
@@ -52,7 +52,7 @@ export function renderComparisonPicker(user, reviews) {
52
52
  const reviewOptions = reviews.map(r => `<option value="${r.id}">${r.name || r.title || 'Untitled'} ${r.status ? '(' + r.status + ')' : ''}</option>`).join('');
53
53
  const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Compare Reviews</h1><a href="/reviews" class="btn btn-ghost btn-sm">Back to Reviews</a></div><div class="card-clean" style="margin:autorem"><div class="card-clean-body"><p class="text-sm text-gray-500 mb-4">Select two reviews to compare their highlights side by side.</p><div class="form-group mb-4"><label class="text-sm font-medium block mb-1" for="left-review">Left Review</label><select id="left-review" class="select select-bordered w-full">${reviewOptions}</select></div><div class="form-group mb-4"><label class="text-sm font-medium block mb-1" for="right-review">Right Review</label><select id="right-review" class="select select-bordered w-full">${reviewOptions}</select></div><button class="btn btn-primary w-full" data-action="startComparison">Compare</button></div></div>`;
54
54
 
55
- const script = `window.startComparison=function(){const l=document.getElementById('left-review')?.value;const r=document.getElementById('right-review')?.value;if(!l||!r)return alert('Select both reviews');if(l===r)return alert('Select different reviews');window.location='/reviews/compare?left='+l+'&right='+r}`;
55
+ const script = `window.startComparison=function(){const l=document.getElementById('left-review')?.value;const r=document.getElementById('right-review')?.value;if(!l||!r)return showToast('Select both reviews','error');if(l===r)return showToast('Select different reviews','error');window.location='/reviews/compare?left='+l+'&right='+r}`;
56
56
 
57
57
  return page(user, 'Compare Reviews | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/reviews', label: 'Reviews' }, { label: 'Compare' }], content, [script]);
58
58
  }
@@ -2,7 +2,7 @@ import { page } from '@/ui/layout.js';
2
2
  import { reviewZoneNav } from '@/ui/review-zone-nav.js';
3
3
  export { reviewZoneNav };
4
4
  import { canEdit } from '@/ui/permissions-ui.js';
5
- import { esc, statusBadge, TOAST_SCRIPT } from '@/ui/render-helpers.js';
5
+ import { esc, statusBadge, TOAST_SCRIPT, icon } from '@/ui/render-helpers.js';
6
6
  import { reviewDetailScript } from '@/ui/review-detail-script.js';
7
7
  import { highlightRow, collaboratorRow, addCollaboratorDialog } from '@/ui/review-detail-panels.js';
8
8
  import { tenderPanelHtml, tenderDialog, linksPanelHtml, linkDialog, sectionsPanelHtml, compareDialogHtml } from '@/ui/review-detail-panels-extra.js';
@@ -91,7 +91,7 @@ export function renderReviewDetail(user, review, highlights = [], collaborators
91
91
  const flaggedCount = highlights.filter(h => { try { return (JSON.parse(h.flags||'[]')||[]).includes('flagged'); } catch { return false; } }).length;
92
92
  const hRows = highlights.map(h => highlightRow(h)).join('');
93
93
  const highlightsPanel = tablePanel('highlights', 'Highlights', totalH,
94
- `<span style="font-size:13px;color:var(--color-text-muted)">${resolvedH} resolved, ${openH} open</span><button id="btn-filter-flagged" data-action="filterByFlag" style="font-size:12px;padding:4px 10px;border:1px solid ${flaggedCount > 0 ? '#f59e0b' : 'var(--color-border,#e5e7eb)'};background:${flaggedCount > 0 ? '#fffbeb' : 'transparent'};color:${flaggedCount > 0 ? '#92400e' : 'var(--color-text-muted)'};border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;gap:4px">⚑ Flagged${flaggedCount > 0 ? ` (${flaggedCount})` : ''}</button>${renderButton('Open PDF', { variant: 'primary', size: 'sm', href: `/review/${esc(r.id)}/pdf` })}`,
94
+ `<span style="font-size:13px;color:var(--color-text-muted)">${resolvedH} resolved, ${openH} open</span><button id="btn-filter-flagged" data-action="filterByFlag" style="font-size:12px;padding:4px 10px;border:1px solid ${flaggedCount > 0 ? '#f59e0b' : 'var(--color-border,#e5e7eb)'};background:${flaggedCount > 0 ? '#fffbeb' : 'transparent'};color:${flaggedCount > 0 ? '#92400e' : 'var(--color-text-muted)'};border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;gap:4px">${icon('flag', 14)} Flagged${flaggedCount > 0 ? ` (${flaggedCount})` : ''}</button>${renderButton('Open PDF', { variant: 'primary', size: 'sm', href: `/review/${esc(r.id)}/pdf` })}`,
95
95
  ['Highlight', 'Page', 'Status', 'Tags', 'By', 'Actions'], hRows,
96
96
  `<a href="/review/${esc(r.id)}/pdf" style="color:var(--color-primary);font-weight:500;text-decoration:none">Open the PDF</a> to start adding highlights`);
97
97
 
@@ -109,7 +109,7 @@ export function renderReviewDetail(user, review, highlights = [], collaborators
109
109
  ['Name', 'Items', 'Progress', ''], clRows, 'No checklists attached');
110
110
 
111
111
  const checklistPickerDialog = `<div id="checklist-picker-dialog" class="dialog-backdrop" style="display:none" data-dialog-close="checklist-picker-dialog">
112
- <div class="dialog" style="max-width:520px" onclick="event.stopPropagation()">
112
+ <div class="dialog" style="max-width:520px">
113
113
  <div class="dialog-header"><h2 style="font-size:17px;font-weight:600;margin:0">Attach Checklist</h2><button class="dialog-close" data-dialog-close="checklist-picker-dialog" aria-label="Close">&times;</button></div>
114
114
  <div class="dialog-body" style="padding:16px 20px">
115
115
  <label style="font-size:13px;font-weight:500;color:var(--color-text-muted);display:block;margin-bottom:6px">Choose a template</label>
@@ -144,7 +144,7 @@ export function renderReviewDetail(user, review, highlights = [], collaborators
144
144
  const overviewPanel = `<div id="rvpanel-overview" class="rv-panel">${detailsCard}</div>`;
145
145
  const sectionsPanel = sectionsPanelHtml(r.id, sections, SPACING, renderTable, renderButton);
146
146
  const notifyDialog = `<div id="notify-collab-dialog" class="dialog-backdrop" style="display:none" data-dialog-close="notify-collab-dialog">
147
- <div class="dialog" style="max-width:480px" onclick="event.stopPropagation()">
147
+ <div class="dialog" style="max-width:480px">
148
148
  <div class="dialog-header"><h2 style="font-size:17px;font-weight:600;margin:0">Notify Collaborators</h2><button class="dialog-close" data-dialog-close="notify-collab-dialog">&times;</button></div>
149
149
  <div class="dialog-body" style="padding:16px 20px">
150
150
  <label style="font-size:13px;font-weight:500;color:var(--color-text-muted);display:block;margin-bottom:6px">Recipients</label>
@@ -154,13 +154,13 @@ export function renderReviewDetail(user, review, highlights = [], collaborators
154
154
  </div>
155
155
  <div class="dialog-footer" style="display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid var(--color-border,#e5e7eb)">
156
156
  <button class="btn-ghost-clean" data-dialog-close="notify-collab-dialog">Cancel</button>
157
- <button class="btn-primary-clean" id="notify-send-btn" onclick="sendCollabNotify('${esc(r.id)}')">Send Notification</button>
157
+ <button class="btn-primary-clean" id="notify-send-btn" data-action="sendCollabNotify" data-args='["${esc(r.id)}"]'>Send Notification</button>
158
158
  </div>
159
159
  </div>
160
160
  </div>`;
161
161
 
162
162
  const notifyScript = `<script>
163
- async function sendCollabNotify(reviewId){var msg=document.getElementById('notify-message').value.trim();if(!msg){showToast('Enter a message','error');return;}var checked=Array.from(document.querySelectorAll('#notify-recipients input[name="notify_user"]:checked')).map(function(i){return i.value;});if(!checked.length){showToast('Select at least one recipient','error');return;}var btn=document.getElementById('notify-send-btn');if(btn){btn.disabled=true;btn.textContent='Sending...';}try{var r=await fetch('/api/mwr/review/'+reviewId+'/notify',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({user_ids:checked,message:msg})});if(r.ok){showToast('Notification sent','success');document.getElementById('notify-collab-dialog').style.display='none';document.getElementById('notify-message').value='';}else{var d=await r.json();showToast(d.error||'Failed','error');}}catch(e){showToast('Error: '+e.message,'error');}finally{if(btn){btn.disabled=false;btn.textContent='Send Notification';}}}
163
+ window.sendCollabNotify=async function(reviewId){var msg=document.getElementById('notify-message').value.trim();if(!msg){showToast('Enter a message','error');return;}var checked=Array.from(document.querySelectorAll('#notify-recipients input[name="notify_user"]:checked')).map(function(i){return i.value;});if(!checked.length){showToast('Select at least one recipient','error');return;}var btn=document.getElementById('notify-send-btn');if(btn){btn.disabled=true;btn.textContent='Sending...';}try{var r=await fetch('/api/mwr/review/'+reviewId+'/notify',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({user_ids:checked,message:msg})});if(r.ok){showToast('Notification sent','success');document.getElementById('notify-collab-dialog').style.display='none';document.getElementById('notify-message').value='';}else{var d=await r.json();showToast(d.error||'Failed','error');}}catch(e){showToast('Error: '+e.message,'error');}finally{if(btn){btn.disabled=false;btn.textContent='Send Notification';}}}
164
164
  </script>`;
165
165
 
166
166
  const content = `${pageHeader}${reviewZoneNav(r.id, 'overview')}${tabBar}
@@ -1,8 +1,11 @@
1
1
  import { page } from '@/ui/layout.js';
2
2
  import { reviewZoneNav } from '@/ui/review-zone-nav.js';
3
3
  import { SPACING, renderCard, renderButton, renderProgress, renderEmptyState, renderStatsRow, renderPageHeader } from '@/ui/spacing-system.js';
4
+ import { icon, esc } from '@/ui/format-helpers.js';
5
+ import { TOAST_SCRIPT } from '@/ui/render-helpers.js';
4
6
 
5
- const TOAST_SCRIPT = `window.showToast=(m,t='info')=>{let c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';c.setAttribute('role','status');c.setAttribute('aria-live','polite');c.setAttribute('aria-atomic','true');document.body.appendChild(c)}const d=document.createElement('div');d.className='toast toast-'+t;d.textContent=m;c.appendChild(d);setTimeout(()=>{d.style.opacity='0';setTimeout(()=>d.remove(),300)},3000)};`;
7
+ // Star icon with fill toggle for priority on/off state.
8
+ const starIcon = (on) => `<svg width="16" height="16" viewBox="0 0 24 24" fill="${on ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
6
9
 
7
10
  function fmtDate(ts) {
8
11
  if (!ts) return '-';
@@ -41,29 +44,29 @@ export function renderMwrHome(user, stats) {
41
44
  </nav>`;
42
45
 
43
46
  function listItem(r) {
44
- const star = r.priority || r.starred ? '★' : '☆';
47
+ const isPri = !!(r.priority || r.starred);
45
48
  const archiveLabel = r.status === 'archived' ? 'Unarchive' : 'Archive';
46
49
  const archiveAct = r.status === 'archived' ? 'unarchive' : 'archive';
47
50
  return `<div class="list-item-row" data-review-id="${r.id}">
48
- <button class="row-action row-action-star" data-action="toggleReviewStar" data-args='["${r.id}"]' aria-label="Toggle priority" title="Star/Priority">${star}</button>
49
- <span class="list-item-name" data-action="openReview" data-args='["${r.id}"]' style="cursor:pointer;flex:1">${r.name || 'Untitled'}</span>
51
+ <button type="button" class="row-action row-action-star${isPri ? ' is-priority' : ''}" data-action="toggleReviewStar" data-args='["${esc(r.id)}"]' aria-label="Toggle priority" aria-pressed="${isPri}" title="Star/Priority">${starIcon(isPri)}</button>
52
+ <span class="list-item-name" data-action="openReview" data-args='["${esc(r.id)}"]' style="cursor:pointer;flex:1">${esc(r.name || 'Untitled')}</span>
50
53
  <div class="list-item-meta">${statusBadge(r.status)}<span style="font-size:12px;color:var(--color-text-light)">${fmtDate(r.updated_at || r.created_at)}</span></div>
51
- <button class="row-action row-action-flag" data-action="toggleReviewFlag" data-args='["${r.id}"]' aria-label="Flag" title="Flag">⚑</button>
52
- <button class="row-action row-action-tag" data-action="openReviewTag" data-args='["${r.id}"]' aria-label="Tag" title="Tag">#</button>
53
- <button class="row-action row-action-archive" data-action="toggleReviewArchive" data-args='["${r.id}","${archiveAct}"]' aria-label="${archiveLabel}" title="${archiveLabel}">${r.status === 'archived' ? '' : '🗄'}</button>
54
- <button class="row-action row-action-open" data-action="openReview" data-args='["${r.id}"]' aria-label="Open" title="Open">↗</button>
54
+ <button class="row-action row-action-flag" data-action="toggleReviewFlag" data-args='["${r.id}"]' aria-label="Flag" title="Flag">${icon('flag', 16)}</button>
55
+ <button class="row-action row-action-tag" data-action="openReviewTag" data-args='["${r.id}"]' aria-label="Tag" title="Tag">${icon('tag', 16)}</button>
56
+ <button class="row-action row-action-archive" data-action="toggleReviewArchive" data-args='["${r.id}","${archiveAct}"]' aria-label="${archiveLabel}" title="${archiveLabel}">${r.status === 'archived' ? icon('undo', 16) : icon('archive', 16)}</button>
57
+ <button class="row-action row-action-open" data-action="openReview" data-args='["${r.id}"]' aria-label="Open" title="Open">${icon('external', 16)}</button>
55
58
  </div>`;
56
59
  }
57
60
 
58
61
  const activeList = active.length ? active.map(listItem).join('') : renderEmptyState('No active reviews');
59
62
  const priorityList = priority.length ? priority.map(listItem).join('') : renderEmptyState('No priority reviews');
60
63
  const archiveList = archive.length ? archive.map(listItem).join('') : renderEmptyState('No archived reviews');
61
- const historyList = history.length ? history.slice(0, 50).map(a => `<div class="activity-item-row"><span class="activity-item-date">${fmtDate(a.created_at)}</span><span class="activity-item-desc">${a.description || a.action || '-'}</span></div>`).join('') : renderEmptyState('No recent activity');
64
+ const historyList = history.length ? history.slice(0, 50).map(a => `<div class="activity-item-row"><span class="activity-item-date">${fmtDate(a.created_at)}</span><span class="activity-item-desc">${esc(a.description || a.action || '-')}</span></div>`).join('') : renderEmptyState('No recent activity');
62
65
 
63
66
  const panels = `<div id="home-panel-active">${activeList}</div><div id="home-panel-priority" style="display:none">${priorityList}</div><div id="home-panel-history" style="display:none">${historyList}</div><div id="home-panel-archive" style="display:none">${archiveList}</div>`;
64
67
 
65
68
  const content = `${renderPageHeader('MWR Home', `Welcome back, ${user?.name || 'User'}`)}${statsHtml}${tabBar}${renderCard(panels, { padding: 0 })}`;
66
- const script = `${TOAST_SCRIPT}window.switchHomeTab=(key)=>{document.querySelectorAll('.tab-btn[data-tab]').forEach(t=>t.classList.toggle('active',t.dataset.tab===key));document.querySelectorAll('[id^="home-panel-"]').forEach(p=>p.style.display='none');const el=document.getElementById('home-panel-'+key);if(el)el.style.display='block'};window.openReview=(id)=>{location.href='/review/'+id};window.toggleReviewStar=async(id)=>{try{const r=await fetch('/api/mwr/review/star',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Star failed','error')}catch(e){window.showToast('Star failed','error')}};window.toggleReviewFlag=async(id)=>{try{const r=await fetch('/api/mwr/review/flag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Flag failed','error')}catch(e){window.showToast('Flag failed','error')}};window.openReviewTag=(id)=>{const t=prompt('Tag:');if(t)fetch('/api/mwr/review/tag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id,tag:t})}).then(r=>{if(r.ok)window.showToast('Tagged');else window.showToast('Tag failed','error')})};window.toggleReviewArchive=async(id,act)=>{try{const r=await fetch('/api/mwr/review/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_ids:[id],action:act})});if(r.ok)location.reload();else window.showToast('Archive failed','error')}catch(e){window.showToast('Archive failed','error')}};`;
69
+ const script = `${TOAST_SCRIPT}window.switchHomeTab=(key)=>{document.querySelectorAll('.tab-btn[data-tab]').forEach(t=>t.classList.toggle('active',t.dataset.tab===key));document.querySelectorAll('[id^="home-panel-"]').forEach(p=>p.style.display='none');const el=document.getElementById('home-panel-'+key);if(el)el.style.display='block'};window.openReview=(id)=>{location.href='/review/'+id};window.toggleReviewStar=async(id)=>{try{const r=await fetch('/api/mwr/review/star',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Star failed','error')}catch(e){window.showToast('Star failed','error')}};window.toggleReviewFlag=async(id)=>{try{const r=await fetch('/api/mwr/review/flag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Flag failed','error')}catch(e){window.showToast('Flag failed','error')}};window.openReviewTag=async(id)=>{const t=await window.gmPrompt({title:'Tag Review',label:'Tag',placeholder:'Enter a tag',confirmLabel:'Add Tag'});if(t)fetch('/api/mwr/review/tag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id,tag:t})}).then(r=>{if(r.ok)window.showToast('Tagged');else window.showToast('Tag failed','error')})};window.toggleReviewArchive=async(id,act)=>{try{const r=await fetch('/api/mwr/review/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_ids:[id],action:act})});if(r.ok)location.reload();else window.showToast('Archive failed','error')}catch(e){window.showToast('Archive failed','error')}};`;
67
70
  const bc = [{ href: '/', label: 'Dashboard' }, { label: 'MWR Home' }];
68
71
  return page(user, 'MWR Home | Moonlanding', bc, content, [script]);
69
72
  }
@@ -1,5 +1,5 @@
1
1
  import { h } from '@/ui/webjsx.js'
2
- import { statusLabel, TOAST_SCRIPT } from '@/ui/render-helpers.js'
2
+ import { statusLabel, TOAST_SCRIPT, esc } from '@/ui/render-helpers.js'
3
3
  import { teamAvatarGroup } from '@/ui/widgets.js'
4
4
 
5
5
  export function mobileReviewCard(review) {
@@ -8,8 +8,8 @@ export function mobileReviewCard(review) {
8
8
  const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at}</span>` : ''
9
9
  return `<div class="mobile-card mobile-review-card card-clean">
10
10
  <div class="card-clean-body">
11
- <div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${r.name || r.title || 'Untitled Review'}</h3>${sts}</div>
12
- <div class="flex items-center gap-2 text-xs text-gray-500 mb-2">${r.engagement_name ? `<span>${r.engagement_name}</span>` : ''}${date}</div>
11
+ <div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${esc(r.name || r.title || 'Untitled Review')}</h3>${sts}</div>
12
+ <div class="flex items-center gap-2 text-xs text-gray-500 mb-2">${r.engagement_name ? `<span>${esc(r.engagement_name)}</span>` : ''}${date}</div>
13
13
  ${r.highlights_count !== undefined ? `<div class="text-xs text-gray-500">${r.highlights_count} highlight${r.highlights_count !== 1 ? 's' : ''}</div>` : ''}
14
14
  <div class="flex gap-1 mt-2"><a href="/review/${r.id}" class="btn btn-xs btn-primary">View</a><a href="/review/${r.id}/edit" class="btn btn-xs btn-outline">Edit</a></div>
15
15
  </div></div>`
@@ -37,7 +37,7 @@ export function sidebarReviewDetails(review) {
37
37
  }
38
38
 
39
39
  export function archiveReviewDialog() {
40
- return `<div id="archive-review-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="archive-review-dialog-title" aria-hidden="true">
40
+ return `<div id="archive-review-dialog" class="dialog-overlay" style="display:none" data-dialog-close="archive-review-dialog" role="dialog" aria-modal="true" aria-labelledby="archive-review-dialog-title" aria-hidden="true">
41
41
  <div class="dialog-panel">
42
42
  <div class="dialog-header"><span class="dialog-title" id="archive-review-dialog-title">Archive Review</span><button class="dialog-close" data-dialog-close="archive-review-dialog" aria-label="Close dialog">&times;</button></div>
43
43
  <div class="dialog-body">
@@ -73,7 +73,7 @@ export function reviewPrivateToggle(reviewId, isPrivate) {
73
73
  }
74
74
 
75
75
  export function markAllHighlightsResolved(reviewId, unresolvedCount = 0) {
76
- return `<div id="bulk-resolve-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="bulk-resolve-dialog-title" aria-hidden="true">
76
+ return `<div id="bulk-resolve-dialog" class="dialog-overlay" style="display:none" data-dialog-close="bulk-resolve-dialog" role="dialog" aria-modal="true" aria-labelledby="bulk-resolve-dialog-title" aria-hidden="true">
77
77
  <div class="dialog-panel" style="max-width:380px">
78
78
  <div class="dialog-header"><span class="dialog-title" id="bulk-resolve-dialog-title">Resolve All Highlights</span><button class="dialog-close" data-dialog-close="bulk-resolve-dialog" aria-label="Close dialog">&times;</button></div>
79
79
  <div class="dialog-body">