thatcher 1.0.6 → 1.0.33
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/README.md +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +29 -88
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +14 -5
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
|
|
2
|
-
import { esc } from '@/ui/render-helpers.js';
|
|
2
|
+
import { esc, icon } from '@/ui/render-helpers.js';
|
|
3
3
|
|
|
4
4
|
const bc = (label) => [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label }];
|
|
5
|
-
const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/
|
|
5
|
+
const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/80">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
|
|
6
6
|
|
|
7
7
|
export function renderSettingsReviewSettings(user, config = {}) {
|
|
8
8
|
const review = config.review || {};
|
|
@@ -39,7 +39,7 @@ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
|
|
|
39
39
|
{ id: 'mobile_resize', label: 'Mobile Resize', desc: 'Enable mobile-friendly resizable highlights', checked: fr.mobile_resize !== false },
|
|
40
40
|
{ id: 'coordinate_snap', label: 'Coordinate Snap', desc: 'Snap highlight coordinates to text boundaries', checked: !!fr.coordinate_snap },
|
|
41
41
|
];
|
|
42
|
-
const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button"
|
|
42
|
+
const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button" data-action="removeFlag" data-args='${esc(JSON.stringify([type, f]))}' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;line-height:1;color:inherit;display:inline-flex">${icon('close',16)}</button></span>`).join('');
|
|
43
43
|
const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">File Review Settings</h1>
|
|
44
44
|
<div id="fr-review-flags" data-flags='${JSON.stringify(reviewFlags)}'></div>
|
|
45
45
|
<div id="fr-tender-flags" data-flags='${JSON.stringify(tenderFlags)}'></div>
|
|
@@ -55,18 +55,19 @@ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
|
|
|
55
55
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
56
56
|
<div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Review Flags</h2>
|
|
57
57
|
<div id="review-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(reviewFlags, 'review')}</div>
|
|
58
|
-
<div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button"
|
|
58
|
+
<div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["review"]' class="btn btn-primary btn-sm">Add</button></div>
|
|
59
59
|
</div></div>
|
|
60
60
|
<div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Tender Flags</h2>
|
|
61
61
|
<div id="tender-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(tenderFlags, 'tender')}</div>
|
|
62
|
-
<div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button"
|
|
62
|
+
<div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["tender"]' class="btn btn-primary btn-sm">Add</button></div>
|
|
63
63
|
</div></div>
|
|
64
64
|
</div>
|
|
65
65
|
<button type="submit" class="btn btn-primary">Save File Review Settings</button></form>`;
|
|
66
66
|
const script = `${TOAST_SCRIPT}
|
|
67
67
|
var _rvFlags=${JSON.stringify(reviewFlags)};
|
|
68
68
|
var _tdFlags=${JSON.stringify(tenderFlags)};
|
|
69
|
-
function
|
|
69
|
+
function _escFlag(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
70
|
+
function renderFlags(type){const list=type==='review'?_rvFlags:_tdFlags;const el=document.getElementById(type+'-flags-list');if(!el)return;el.innerHTML=list.map(f=>'<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">'+_escFlag(f)+'<button type="button" data-action="removeFlag" data-args=\\''+_escFlag(JSON.stringify([type,f]))+'\\' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">×</button></span>').join('')}
|
|
70
71
|
window.addFlag=function(type){const inp=document.getElementById('new-'+type+'-flag');const val=(inp?.value||'').trim();if(!val)return;if(type==='review'){_rvFlags=[..._rvFlags,val]}else{_tdFlags=[..._tdFlags,val]};inp.value='';renderFlags(type)};
|
|
71
72
|
window.removeFlag=function(type,flag){if(type==='review'){_rvFlags=_rvFlags.filter(f=>f!==flag)}else{_tdFlags=_tdFlags.filter(f=>f!==flag)};renderFlags(type)};
|
|
72
73
|
document.getElementById('file-review-settings-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#file-review-settings-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});document.querySelectorAll('#file-review-settings-form input[type=number]').forEach(n=>{if(data[n.name])data[n.name]=Number(data[n.name])});data.review_flags=_rvFlags;data.tender_flags=_tdFlags;try{const res=await fetch('/api/admin/settings/file-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}})`;
|
|
@@ -79,31 +80,31 @@ export function renderSettingsMwrPermissions(user, permissions = []) {
|
|
|
79
80
|
<td class="text-sm">${esc(p.entity_id||'-')}</td>
|
|
80
81
|
<td class="text-sm">${esc(p.user_id||'-')}</td>
|
|
81
82
|
<td class="text-sm">${esc(p.permission_type||'-')}</td>
|
|
82
|
-
<td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString() : '-'}</td>
|
|
83
|
-
<td><button class="btn btn-error btn-xs btn-outline"
|
|
83
|
+
<td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
|
|
84
|
+
<td><button type="button" class="btn btn-error btn-xs btn-outline" data-action="revokePermission" data-args='${esc(JSON.stringify([p.id]))}'>Revoke</button></td>
|
|
84
85
|
</tr>`).join('');
|
|
85
86
|
const content = `${settingsBack()}<div class="flex justify-between items-center mb-6">
|
|
86
87
|
<h1 class="text-2xl font-bold">MWR Permissions</h1>
|
|
87
|
-
<button class="btn btn-primary btn-sm"
|
|
88
|
+
<button class="btn btn-primary btn-sm" data-action="openDialog" data-args='["grant-perm-dialog"]'>+ Grant Permission</button>
|
|
88
89
|
</div>
|
|
89
90
|
${inlineTable(['Entity Type','Entity ID','User ID','Permission','Granted','Actions'], rows, 'No permissions configured')}
|
|
90
|
-
<div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
|
|
91
|
-
<div class="dialog-panel" style="max-width:460px">
|
|
92
|
-
<div class="dialog-header"><span class="dialog-title">Grant Permission</span><button class="dialog-close"
|
|
91
|
+
<div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="gp-dialog-title" data-dialog-close="grant-perm-dialog">
|
|
92
|
+
<div class="dialog-panel" style="max-width:min(460px,90vw)">
|
|
93
|
+
<div class="dialog-header"><span class="dialog-title" id="gp-dialog-title">Grant Permission</span><button class="dialog-close" data-dialog-close="grant-perm-dialog" aria-label="Close">×</button></div>
|
|
93
94
|
<div class="dialog-body">
|
|
94
|
-
<div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="review"/></div>
|
|
95
|
-
<div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="
|
|
96
|
-
<div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="
|
|
95
|
+
<div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="e.g. review"/></div>
|
|
96
|
+
<div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="e.g. 1a2b3c"/></div>
|
|
97
|
+
<div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="e.g. user_42"/></div>
|
|
97
98
|
<div class="modal-form-group"><label>Permission Type</label><select id="gp-perm-type" class="form-input"><option value="view">view</option><option value="edit">edit</option><option value="admin">admin</option></select></div>
|
|
98
99
|
<div id="gp-result" style="display:none;margin-top:8px"></div>
|
|
99
100
|
</div>
|
|
100
101
|
<div class="dialog-footer">
|
|
101
|
-
<button class="btn btn-ghost btn-sm"
|
|
102
|
-
<button class="btn btn-primary btn-sm"
|
|
102
|
+
<button class="btn btn-ghost btn-sm" data-dialog-close="grant-perm-dialog">Cancel</button>
|
|
103
|
+
<button class="btn btn-primary btn-sm" data-action="grantPermission">Grant</button>
|
|
103
104
|
</div>
|
|
104
105
|
</div>
|
|
105
106
|
</div>`;
|
|
106
|
-
const script = `${TOAST_SCRIPT}async function
|
|
107
|
+
const script = `${TOAST_SCRIPT}window.grantPermission=async function(){var body={entity_type:document.getElementById('gp-entity-type').value.trim(),entity_id:document.getElementById('gp-entity-id').value.trim(),user_id:document.getElementById('gp-user-id').value.trim(),permission_type:document.getElementById('gp-perm-type').value};var res=document.getElementById('gp-result');if(!body.entity_type||!body.entity_id||!body.user_id){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">All fields required.</div>';return}try{var r=await fetch('/api/mwr/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var d=await r.json();if(r.ok&&d.success){showToast('Permission granted','success');(window.closeDialog?window.closeDialog('grant-perm-dialog'):document.getElementById('grant-perm-dialog').style.display='none');setTimeout(function(){location.reload()},500)}else{res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||'Failed')+'</div>'}}catch(e){showToast('Error: '+e.message,'error')}};window.revokePermission=async function(id){if(!(await window.gmConfirm({title:'Revoke permission',message:'Revoke this permission?',confirmLabel:'Revoke',danger:true})))return;try{var r=await fetch('/api/permission/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
|
|
107
108
|
return settingsPage(user, 'MWR Permissions', bc('MWR Permissions'), content, [script]);
|
|
108
109
|
}
|
|
109
110
|
|
|
@@ -153,6 +154,6 @@ export function renderSettingsTemplateManage(user, template = {}, sections = [])
|
|
|
153
154
|
window.editTplSection=function(id,name,color){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value=id;document.getElementById('tpl-sec-name').value=name;document.getElementById('tpl-sec-color').value=color};
|
|
154
155
|
window.cancelTplSection=function(){document.getElementById('tpl-section-form').classList.add('hidden')};
|
|
155
156
|
window.saveTplSection=async function(){var id=document.getElementById('tpl-sec-id').value;var body={name:document.getElementById('tpl-sec-name').value,color:document.getElementById('tpl-sec-color').value,review_template_id:tplId};if(!body.name){showToast('Name required','error');return}var url=id?'/api/review_template_section/'+id:'/api/review_template_section';var method=id?'PUT':'POST';try{var res=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};
|
|
156
|
-
window.deleteTplSection=async function(id){if(!
|
|
157
|
+
window.deleteTplSection=async function(id){if(!(await window.gmConfirm({title:'Delete Section',message:'Delete this section? This cannot be undone.',confirmLabel:'Delete',danger:true})))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
|
|
157
158
|
return settingsPage(user, `Manage Template - ${esc(template.name || 'Template')}`, tplBc, content, [script]);
|
|
158
159
|
}
|
|
@@ -14,8 +14,8 @@ function parseMembers(t) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function renderSettingsTeams(user, teams = [], allUsers = []) {
|
|
17
|
-
const editDialog = `<div id="team-dialog" class="modal" style="display:none"
|
|
18
|
-
<div class="modal-overlay" data-
|
|
17
|
+
const editDialog = `<div id="team-dialog" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="team-dialog-title">
|
|
18
|
+
<div class="modal-overlay" data-dialog-close="team-dialog"></div>
|
|
19
19
|
<div class="modal-content rounded-box max-w-md p-6">
|
|
20
20
|
<h3 id="team-dialog-title" class="text-lg font-semibold mb-4">Edit Team</h3>
|
|
21
21
|
<input type="hidden" id="team-edit-id"/>
|
|
@@ -59,7 +59,7 @@ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAl
|
|
|
59
59
|
const content = `${settingsBack()}${editDialog}
|
|
60
60
|
<div class="flex justify-between items-center mb-4">
|
|
61
61
|
<h1 class="text-2xl font-bold">Teams</h1>
|
|
62
|
-
<button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
|
|
62
|
+
<button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
|
|
63
63
|
</div>
|
|
64
64
|
<div class="card-clean"><div class="card-clean-body" style="padding:0rem">${inlineTable(['Name', 'Members', 'Actions'], rows, 'No teams found')}</div></div>`;
|
|
65
65
|
return settingsPage(user, 'Teams - Settings', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Teams' }], content, [script]);
|
|
@@ -101,7 +101,7 @@ export function renderSettingsRfiSections(user, sections = []) {
|
|
|
101
101
|
const script = `${TOAST_SCRIPT}
|
|
102
102
|
function toggleAddForm(){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value='';document.getElementById('section-name').value='';selectColor('#B0B0B0')}
|
|
103
103
|
function cancelForm(){document.getElementById('section-form').style.display='none'}
|
|
104
|
-
function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'
|
|
104
|
+
function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'var(--color-text)':'transparent'})}
|
|
105
105
|
function editSection(id,name,color){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value=id;document.getElementById('section-name').value=name;selectColor(color)}
|
|
106
106
|
async function saveSection(){const name=document.getElementById('section-name').value.trim();const color=document.getElementById('section-color').value;const id=document.getElementById('edit-id').value;if(!name){showToast('Name is required','error');return}const url=id?'/api/rfi_section/'+id:'/api/rfi_section';const method=id?'PUT':'POST';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name,color})});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(()=>location.reload(),500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
|
|
107
107
|
async function deleteSection(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this section?',danger:true,confirmLabel:'OK'})))return;try{const res=await fetch('/api/rfi_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}}
|
|
@@ -50,7 +50,7 @@ const SETTINGS_CARDS = [
|
|
|
50
50
|
export function renderSettingsHome(user, config = {}, counts = {}) {
|
|
51
51
|
const cards = SETTINGS_CARDS.map(c => {
|
|
52
52
|
const cnt = c.countKey && counts[c.countKey] !== undefined ? counts[c.countKey] : null;
|
|
53
|
-
const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString()} items</span>` : '';
|
|
53
|
+
const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString('en-ZA')} items</span>` : '';
|
|
54
54
|
return `<a href="${c.href}" class="card-clean" style="text-decoration:none;border:1px solid var(--color-border);transition:box-shadow 0.15s">
|
|
55
55
|
<div class="card-clean-body" style="padding:${SPACING.md}">
|
|
56
56
|
<div style="display:flex;align-items:flex-start;gap:${SPACING.md}">
|
|
@@ -60,7 +60,7 @@ export function renderSettingsHome(user, config = {}, counts = {}) {
|
|
|
60
60
|
<div style="font-size:0.75rem;color:var(--color-text-muted);margin-top:${SPACING.xs};line-height:1.4">${c.desc}</div>
|
|
61
61
|
${badge ? `<div style="margin-top:${SPACING.sm}">${badge}</div>` : ''}
|
|
62
62
|
</div>
|
|
63
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
|
|
63
|
+
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
|
|
64
64
|
</div>
|
|
65
65
|
</div>
|
|
66
66
|
</a>`;
|
|
@@ -120,10 +120,10 @@ export function renderSettingsUsers(user, users = []) {
|
|
|
120
120
|
<div class="flex justify-between items-center mb-2">
|
|
121
121
|
<div>
|
|
122
122
|
<h1 class="text-2xl font-bold">Users</h1>
|
|
123
|
-
<p class="text-sm text-base-content/50 mt-0.5">${users.length} total
|
|
123
|
+
<p class="text-sm text-base-content/50 mt-0.5">${users.length} total - ${active} active</p>
|
|
124
124
|
</div>
|
|
125
125
|
<a href="/user/new" class="btn btn-primary btn-sm gap-1">
|
|
126
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
|
|
126
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
|
|
127
127
|
</a>
|
|
128
128
|
</div>
|
|
129
129
|
<div class="table-toolbar mb-3">
|
package/src/ui/spacing-system.js
CHANGED
|
@@ -124,11 +124,14 @@ export function renderStatsRow(stats) {
|
|
|
124
124
|
</div>`).join('')}</div>`;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// title/subtitle are plain text (escaped here); actionsHtml is a caller-built
|
|
128
|
+
// HTML fragment by contract. esc() inline to keep this module dependency-free.
|
|
129
|
+
function phEsc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
127
130
|
export function renderPageHeader(title, subtitle, actionsHtml = '') {
|
|
128
131
|
return `<div class="page-header">
|
|
129
132
|
<div>
|
|
130
|
-
<h1 class="page-title">${title}</h1>
|
|
131
|
-
${subtitle ? `<p class="page-subtitle">${subtitle}</p>` : ''}
|
|
133
|
+
<h1 class="page-title">${phEsc(title)}</h1>
|
|
134
|
+
${subtitle ? `<p class="page-subtitle">${phEsc(subtitle)}</p>` : ''}
|
|
132
135
|
</div>
|
|
133
136
|
${actionsHtml ? `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">${actionsHtml}</div>` : ''}
|
|
134
137
|
</div>`;
|
|
@@ -4,7 +4,7 @@ export function renderStandaloneLogin(showGoogleAuth = false) {
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
<title>Sign In |
|
|
7
|
+
<title>Sign In | Thatcher</title>
|
|
8
8
|
<style>
|
|
9
9
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
10
10
|
:root{--bg:#f1f5f9;--surface:#fff;--border:#e2e8f0;--text:#0f172a;--text-muted:#64748b;--primary:#04141f;--primary-hover:#0a2535;--accent:#2563eb;--danger:#dc2626;--danger-bg:#fee2e2;--danger-border:#fca5a5;--radius:10px;--shadow:0 4px 24px rgba(0,0,0,0.09)}
|
|
@@ -45,7 +45,7 @@ export function renderStandaloneLogin(showGoogleAuth = false) {
|
|
|
45
45
|
<div class="wrap">
|
|
46
46
|
<div class="logo">
|
|
47
47
|
<div class="logo-mark"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.75" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
|
48
|
-
<span class="logo-text">
|
|
48
|
+
<span class="logo-text">Thatcher</span>
|
|
49
49
|
<div class="logo-sub">Sign in to your account</div>
|
|
50
50
|
</div>
|
|
51
51
|
<div class="card">
|
package/src/ui/styles.css
CHANGED
|
@@ -1184,9 +1184,10 @@ tr:last-child td { border-bottom: none; }
|
|
|
1184
1184
|
.dialog-panel { background: white; border-radius: 0.75rem; padding: 1.5rem; max-width: 480px; width: 90%; max-height: 85vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2); }
|
|
1185
1185
|
.dialog-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
|
1186
1186
|
.dialog-title { font-size: 1.125rem; font-weight: 600; color: #111827; }
|
|
1187
|
-
.dialog-close { background: none; border: none; font-size: 1.5rem; color: #9ca3af; cursor: pointer; line-height: 1; padding: 0; }
|
|
1187
|
+
.dialog-close { background: none; border: none; font-size: 1.5rem; color: #9ca3af; cursor: pointer; line-height: 1; padding: 0; min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; }
|
|
1188
1188
|
.dialog-close:hover { color: #374151; }
|
|
1189
1189
|
@media (prefers-color-scheme: dark) {
|
|
1190
|
+
.dialog-title { color: #f5f5f5; }
|
|
1190
1191
|
.dialog-close { color: #9ca3af; }
|
|
1191
1192
|
.dialog-close:hover { color: #f5f5f5; }
|
|
1192
1193
|
}
|
package/src/ui/styles2.css
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
--ds-border: #3a3d42;
|
|
12
12
|
--ds-accent: #247420;
|
|
13
13
|
--ds-accent-light: #3A9A34;
|
|
14
|
+
--ds-on-accent: #fff;
|
|
14
15
|
--ds-mint: rgb(182, 222, 193);
|
|
15
16
|
--ds-pill-radius: 999px;
|
|
16
17
|
--ds-font-body: 'Nunito', Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
@@ -23,7 +24,7 @@ body[data-ds-theme="247420"], body { background: var(--ds-bg); color: var(--ds-f
|
|
|
23
24
|
h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3px; }
|
|
24
25
|
.pill, .badge, .info-label, .meta, code, kbd, .mono { font-family: var(--ds-font-mono); }
|
|
25
26
|
.btn, button.btn-primary, button.btn-secondary, .btn-ghost, .btn-outline { border-radius: var(--ds-pill-radius); padding: 8px 18px; font-family: var(--ds-font-body); font-weight: 600; }
|
|
26
|
-
.btn-primary { background: var(--ds-accent); color: var(--ds-
|
|
27
|
+
.btn-primary { background: var(--ds-accent); color: var(--ds-on-accent); border: 1px solid var(--ds-accent-light); }
|
|
27
28
|
.btn-primary:hover { background: var(--ds-accent-light); }
|
|
28
29
|
|
|
29
30
|
/* Layered surfaces */
|
|
@@ -35,7 +36,7 @@ h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3p
|
|
|
35
36
|
#ds-online-status.offline { display: inline-block; background: #4a1d1d; border-color: #6b2828; color: #ffd6d6; }
|
|
36
37
|
|
|
37
38
|
/* FlexUp chat dock */
|
|
38
|
-
#flexup-fab { position: fixed; bottom: 20px; right: 20px; z-index: 9998; width: 52px; height: 52px; border-radius: 999px; background: var(--ds-accent); color:
|
|
39
|
+
#flexup-fab { position: fixed; bottom: 20px; right: 20px; z-index: 9998; width: 52px; height: 52px; border-radius: 999px; background: var(--ds-accent); color: var(--ds-on-accent); border: 1px solid var(--ds-accent-light); cursor: pointer; font-family: var(--ds-font-display); font-size: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
|
|
39
40
|
#flexup-dock { position: fixed; bottom: 84px; right: 20px; z-index: 9998; width: 360px; max-height: 480px; background: var(--ds-surface); border: 1px solid var(--ds-border); border-radius: 12px; display: none; flex-direction: column; box-shadow: 0 12px 32px rgba(0,0,0,0.5); }
|
|
40
41
|
#flexup-dock.open { display: flex; }
|
|
41
42
|
#flexup-dock .fx-header { padding: 12px 14px; border-bottom: 1px solid var(--ds-border); font-family: var(--ds-font-display); font-size: 14px; }
|
|
@@ -45,7 +46,7 @@ h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3p
|
|
|
45
46
|
#flexup-dock .fx-msg.bot { background: var(--ds-bg); border: 1px solid var(--ds-border); }
|
|
46
47
|
#flexup-dock .fx-input-row { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--ds-border); }
|
|
47
48
|
#flexup-dock input.fx-input { flex: 1; background: var(--ds-bg); color: var(--ds-fg); border: 1px solid var(--ds-border); border-radius: 8px; padding: 8px 10px; font-family: var(--ds-font-body); }
|
|
48
|
-
#flexup-dock button.fx-send { background: var(--ds-accent); color: var(--ds-
|
|
49
|
+
#flexup-dock button.fx-send { background: var(--ds-accent); color: var(--ds-on-accent); border: 0; border-radius: 8px; padding: 6px 14px; font-family: var(--ds-font-body); font-weight: 600; cursor: pointer; }
|
|
49
50
|
|
|
50
51
|
/* Priority star */
|
|
51
52
|
.review-priority-star { background: transparent; border: 0; cursor: pointer; color: var(--ds-muted); padding: 2px 6px; font-size: 16px; }
|
|
@@ -78,7 +79,7 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|
|
78
79
|
--color-bg: #f1f5f9;
|
|
79
80
|
--color-border: #e2e8f0;
|
|
80
81
|
--color-text: #0f172a;
|
|
81
|
-
--color-text-muted: #
|
|
82
|
+
--color-text-muted: #5a6b7f;
|
|
82
83
|
--color-text-light: #94a3b8;
|
|
83
84
|
--color-success: #16a34a;
|
|
84
85
|
--color-warning: #d97706;
|
|
@@ -169,13 +170,48 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|
|
169
170
|
|
|
170
171
|
/* Stage pills */
|
|
171
172
|
.stage-pill { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 100px; font-size: 12px; font-weight: 600; letter-spacing: 0.2px; }
|
|
172
|
-
.stage-info_gathering { background: #
|
|
173
|
+
.stage-info_gathering { background: #dbeafe; color: #1e40af; }
|
|
173
174
|
.stage-commencement { background: #fef3c7; color: #92400e; }
|
|
174
175
|
.stage-team_execution { background: #dbeafe; color: #1e40af; }
|
|
175
176
|
.stage-partner_review { background: #ede9fe; color: #5b21b6; }
|
|
176
177
|
.stage-finalization { background: #dcfce7; color: #15803d; }
|
|
177
178
|
.stage-closeout { background: #f1f5f9; color: #475569; }
|
|
178
179
|
|
|
180
|
+
/* Badge variants for table cells and detail views (inherit stage/status pill colors via classes) */
|
|
181
|
+
.badge-stage { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 100px; font-size: 12px; font-weight: 600; }
|
|
182
|
+
.badge-stage.stage-info_gathering { background: #dbeafe; color: #1e40af; }
|
|
183
|
+
.badge-stage.stage-commencement { background: #fef3c7; color: #92400e; }
|
|
184
|
+
.badge-stage.stage-team_execution { background: #dbeafe; color: #1e40af; }
|
|
185
|
+
.badge-stage.stage-partner_review { background: #ede9fe; color: #5b21b6; }
|
|
186
|
+
.badge-stage.stage-finalization { background: #dcfce7; color: #15803d; }
|
|
187
|
+
.badge-stage.stage-closeout { background: #f1f5f9; color: #475569; }
|
|
188
|
+
|
|
189
|
+
/* Status label badges (alias to pill classes for consistency) */
|
|
190
|
+
.status-label { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 100px; font-size: 12px; font-weight: 500; }
|
|
191
|
+
|
|
192
|
+
/* Dark-mode overrides for status pills + stage pills: keep them as dark-tinted
|
|
193
|
+
islands (not light-on-dark) with legible light text. */
|
|
194
|
+
@media (prefers-color-scheme: dark) {
|
|
195
|
+
.pill-success { background: #14321f; color: #4ade80; }
|
|
196
|
+
.pill-danger { background: #3a1717; color: #f87171; }
|
|
197
|
+
.pill-warning { background: #3a2c12; color: #fbbf24; }
|
|
198
|
+
.pill-info { background: #16263f; color: #60a5fa; }
|
|
199
|
+
.pill-neutral { background: #25303d; color: #cbd5e1; }
|
|
200
|
+
.pill-primary { background: #122b3a; color: #38bdf8; }
|
|
201
|
+
.stage-info_gathering { background: #1e3a8a; color: #60a5fa; }
|
|
202
|
+
.stage-commencement { background: #3a2c12; color: #fcd34d; }
|
|
203
|
+
.stage-team_execution { background: #16263f; color: #93c5fd; }
|
|
204
|
+
.stage-partner_review { background: #261b3f; color: #c4b5fd; }
|
|
205
|
+
.stage-finalization { background: #14321f; color: #86efac; }
|
|
206
|
+
.stage-closeout { background: #25303d; color: #cbd5e1; }
|
|
207
|
+
.badge-stage.stage-info_gathering { background: #1e3a8a; color: #60a5fa; }
|
|
208
|
+
.badge-stage.stage-commencement { background: #3a2c12; color: #fcd34d; }
|
|
209
|
+
.badge-stage.stage-team_execution { background: #16263f; color: #93c5fd; }
|
|
210
|
+
.badge-stage.stage-partner_review { background: #261b3f; color: #c4b5fd; }
|
|
211
|
+
.badge-stage.stage-finalization { background: #14321f; color: #86efac; }
|
|
212
|
+
.badge-stage.stage-closeout { background: #25303d; color: #cbd5e1; }
|
|
213
|
+
}
|
|
214
|
+
|
|
179
215
|
/* Buttons */
|
|
180
216
|
.btn-primary-clean { display: inline-flex; align-items: center; gap: 6px; padding: 9px 18px; background: var(--color-primary); color: white; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; text-decoration: none; transition: all 0.15s ease; min-height: 40px; }
|
|
181
217
|
.btn-primary-clean:hover { background: var(--color-primary-hover); color: white; transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
|
@@ -194,6 +230,11 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|
|
194
230
|
.btn-danger-clean:hover { background: #7f1d1d; }
|
|
195
231
|
}
|
|
196
232
|
.btn-danger-clean:active { opacity: 0.8; }
|
|
233
|
+
/* gmConfirm danger:true emits .btn-error, but the runtime-injected `.ds-247420 .btn { background:var(--bg-2) }`
|
|
234
|
+
(specificity 0,2,0) out-weighed a plain .btn-error and rendered it gray (witnessed rgb(37,37,44)).
|
|
235
|
+
!important guarantees destructive-confirm buttons read as danger regardless of injected theme order. */
|
|
236
|
+
.btn-error, button.btn-error, .ds-247420 .btn-error { background: var(--color-danger, #dc2626) !important; color: #fff !important; border-color: var(--color-danger, #dc2626) !important; }
|
|
237
|
+
.btn-error:hover, button.btn-error:hover, .ds-247420 .btn-error:hover { background: #b91c1c !important; border-color: #b91c1c !important; }
|
|
197
238
|
.btn-danger-clean:focus-visible { outline: 2px solid var(--color-danger); outline-offset: 2px; }
|
|
198
239
|
.btn-edit-clean { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: transparent; color: var(--color-info); border: 1px solid #bfdbfe; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; text-decoration: none; transition: all 0.15s ease; min-height: 36px; }
|
|
199
240
|
.btn-edit-clean:hover { background: #eff6ff; }
|
|
@@ -768,6 +809,11 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
768
809
|
.main-nav-brand-link { display: flex; align-items: center; gap: 0.5rem; text-decoration: none; flex-shrink: 0; }
|
|
769
810
|
.main-nav-brand-text { color: #f8f9fa; font-size: 0.9rem; font-weight: 700; letter-spacing: 0; margin-left: 0.25rem; }
|
|
770
811
|
.main-nav-actions { display: flex; align-items: center; gap: 0.75rem; margin-left: 1rem; padding-right: 1rem; flex-shrink: 0; }
|
|
812
|
+
/* Shared nav icon-button (theme toggle, notification bell, hamburger): one rule
|
|
813
|
+
instead of duplicated inline style strings on each element. */
|
|
814
|
+
.nav-icon-btn { background: none; border: none; color: var(--color-text-muted, #ced4da); cursor: pointer; padding: 6px; border-radius: 4px; display: flex; align-items: center; min-width: 32px; min-height: 32px; text-decoration: none; transition: color 0.15s, background 0.15s; }
|
|
815
|
+
.nav-icon-btn:hover { color: var(--color-text, #fff); background: rgba(255,255,255,0.08); }
|
|
816
|
+
.nav-icon-btn.nav-notif-bell { position: relative; }
|
|
771
817
|
.nav-links-desktop { display: flex; align-items: center; gap: 1.25rem; flex-shrink: 0; padding: 0 0.75rem; }
|
|
772
818
|
@media (max-width: 768px) { .nav-links-desktop { display: none; } }
|
|
773
819
|
.nav-link { color: #adb5bd; font-size: 0.875rem; font-weight: 500; letter-spacing: 0; text-decoration: none; transition: color 0.15s; }
|
|
@@ -1180,6 +1226,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
1180
1226
|
.opacity-25 { opacity: 0.25; }
|
|
1181
1227
|
.opacity-50 { opacity: 0.5; }
|
|
1182
1228
|
.opacity-75 { opacity: 0.75; }
|
|
1229
|
+
/* read notification row: distinguish via muted bg instead of low-contrast opacity */
|
|
1230
|
+
tr.is-read { background: rgb(var(--base-200, 240 240 244) / 0.4); }
|
|
1231
|
+
tr.is-read td { color: rgb(var(--base-content, 30 30 35) / 0.7); }
|
|
1183
1232
|
.opacity-100 { opacity: 1; }
|
|
1184
1233
|
|
|
1185
1234
|
/* Object fit */
|
|
@@ -1357,6 +1406,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
1357
1406
|
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 600; }
|
|
1358
1407
|
.tab-count { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 100px; font-size: 11px; font-weight: 600; padding: 1px 7px; color: var(--color-text-muted); }
|
|
1359
1408
|
.tab-btn.active .tab-count { background: var(--color-primary); color: #fff; border-color: transparent; }
|
|
1409
|
+
/* Dark-mode --color-primary (#64b5f6) is light; white text on it is 2.2:1 (fails AA).
|
|
1410
|
+
Use dark text on the active-tab count badge in dark theme (witnessed on /review). */
|
|
1411
|
+
[data-theme="dark"] .tab-btn.active .tab-count { color: #0b1220; }
|
|
1360
1412
|
|
|
1361
1413
|
/* Form improvements */
|
|
1362
1414
|
.form-field { margin-bottom: 1rem; }
|
|
@@ -1473,6 +1525,24 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
|
|
|
1473
1525
|
--panel-select: rgba(255,255,255,0.10);
|
|
1474
1526
|
--panel-accent: #B8FF00;
|
|
1475
1527
|
--panel-accent-fg: #0B0B09;
|
|
1528
|
+
/* Manual dark toggle must drive the --color-* system too: the @media (prefers-color-scheme: dark)
|
|
1529
|
+
block below only fires on OS preference, so without these the toggle left --color-text* dark
|
|
1530
|
+
on a dark --color-bg (witnessed 1.4:1 date cells / breadcrumb-sep on /review). */
|
|
1531
|
+
--color-primary: #64b5f6;
|
|
1532
|
+
--color-primary-hover: #42a5f5;
|
|
1533
|
+
--color-surface: #1a1a1a;
|
|
1534
|
+
--color-bg: #0f172a;
|
|
1535
|
+
--color-border: #334155;
|
|
1536
|
+
--color-text: #f1f5f9;
|
|
1537
|
+
--color-text-muted: #cbd5e1;
|
|
1538
|
+
--color-text-light: #94a3b8;
|
|
1539
|
+
--color-success: #4ade80;
|
|
1540
|
+
--color-warning: #fbbf24;
|
|
1541
|
+
--color-danger: #f87171;
|
|
1542
|
+
--color-info: #60a5fa;
|
|
1543
|
+
--shadow-sm: 0 1px 3px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.3);
|
|
1544
|
+
--shadow-md: 0 4px 6px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
|
|
1545
|
+
--shadow-lg: 0 10px 15px rgba(0,0,0,0.6), 0 4px 6px rgba(0,0,0,0.4);
|
|
1476
1546
|
}
|
|
1477
1547
|
|
|
1478
1548
|
@media (prefers-color-scheme: dark) {
|
package/src/ui/test-page.js
CHANGED
|
@@ -4,7 +4,7 @@ export function generateTestPage() {
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
<title>System Test |
|
|
7
|
+
<title>System Test | Thatcher</title>
|
|
8
8
|
<style>
|
|
9
9
|
body { font-family: monospace; padding: 2rem; background: #04141f; color: #ced4da; }
|
|
10
10
|
h1 { color: #3b82f6; }
|
|
@@ -13,9 +13,9 @@ export function generateTestPage() {
|
|
|
13
13
|
</style>
|
|
14
14
|
</head>
|
|
15
15
|
<body>
|
|
16
|
-
<h1>
|
|
17
|
-
<p class="check"
|
|
18
|
-
<p class="check"
|
|
16
|
+
<h1>Thatcher System Test</h1>
|
|
17
|
+
<p class="check">[x] Server is running</p>
|
|
18
|
+
<p class="check">[x] Page handler is operational</p>
|
|
19
19
|
<p class="info">Timestamp: ${new Date().toISOString()}</p>
|
|
20
20
|
<p class="info">Environment: ${process.env.NODE_ENV || 'development'}</p>
|
|
21
21
|
</body>
|
package/src/ui/widgets.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { h } from '@/ui/webjsx.js'
|
|
2
|
-
import { STAGE_COLORS, AVATAR_COLORS, AVATAR_SIZES, nameHash, getInitials } from '@/ui/render-helpers.js'
|
|
2
|
+
import { STAGE_COLORS, AVATAR_COLORS, AVATAR_SIZES, nameHash, getInitials, esc } from '@/ui/render-helpers.js'
|
|
3
3
|
|
|
4
4
|
export function linearProgress(value = 0, max = 100, label = '', variant = 'medium') {
|
|
5
5
|
const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
|
|
@@ -19,7 +19,7 @@ export function circularProgress(value = 0, max = 100, label = '') {
|
|
|
19
19
|
const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
|
|
20
20
|
const color = pct < 30 ? '#ef4444' : pct < 70 ? '#f59e0b' : '#22c55e'
|
|
21
21
|
const r = 40, circ = 2 * Math.PI * r, offset = circ - (pct / 100) * circ
|
|
22
|
-
const ariaLabel = label || 'Progress'
|
|
22
|
+
const ariaLabel = esc(label || 'Progress')
|
|
23
23
|
return `<div class="circular-progress" style="width:100px;height:100px" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="${ariaLabel}">
|
|
24
24
|
<svg width="100" height="100" viewBox="0 0 100 100" aria-hidden="true">
|
|
25
25
|
<circle cx="50" cy="50" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="8"/>
|
|
@@ -28,7 +28,7 @@ export function circularProgress(value = 0, max = 100, label = '') {
|
|
|
28
28
|
</svg>
|
|
29
29
|
<div class="circular-progress-text" aria-hidden="true">
|
|
30
30
|
<span class="circular-progress-pct">${pct}%</span>
|
|
31
|
-
${label ? `<span class="circular-progress-label">${label}</span>` : ''}
|
|
31
|
+
${label ? `<span class="circular-progress-label">${esc(label)}</span>` : ''}
|
|
32
32
|
</div>
|
|
33
33
|
</div>`
|
|
34
34
|
}
|
|
@@ -46,10 +46,12 @@ export function engagementProgress(stage, stages = null) {
|
|
|
46
46
|
|
|
47
47
|
export function emptyState(icon = '', title = '', message = '', actionHref = '', actionLabel = '') {
|
|
48
48
|
const btn = actionHref && actionLabel ? h('a', { href: actionHref, className: 'btn btn-primary btn-sm' }, actionLabel) : ''
|
|
49
|
+
// icon is a trusted inline-SVG/markup fragment from the caller; title/message are
|
|
50
|
+
// text and must be escaped (webjsx h() does NOT escape string children).
|
|
49
51
|
return h('div', { className: 'empty-state' },
|
|
50
|
-
h('div', { className: 'empty-state-icon'
|
|
51
|
-
h('div', { className: 'empty-state-title' }, title),
|
|
52
|
-
h('div', { className: 'empty-state-msg' }, message),
|
|
52
|
+
h('div', { className: 'empty-state-icon', innerHTML: icon }),
|
|
53
|
+
h('div', { className: 'empty-state-title' }, esc(title)),
|
|
54
|
+
h('div', { className: 'empty-state-msg' }, esc(message)),
|
|
53
55
|
btn
|
|
54
56
|
)
|
|
55
57
|
}
|
|
@@ -69,7 +71,8 @@ export function userAvatar(user, size = 'md', showStatus = false) {
|
|
|
69
71
|
const color = AVATAR_COLORS[nameHash(name) % AVATAR_COLORS.length]
|
|
70
72
|
const fontSize = Math.round(px * 0.4)
|
|
71
73
|
const statusDot = showStatus ? `<span class="avatar-status avatar-status-${user?.status === 'active' || user?.online ? 'online' : 'offline'}" style="width:${Math.round(px * 0.3)}px;height:${Math.round(px * 0.3)}px"></span>` : ''
|
|
72
|
-
|
|
74
|
+
const safeName = esc(name)
|
|
75
|
+
return `<span class="user-avatar user-avatar-${size}" style="width:${px}px;height:${px}px;background:${color};font-size:${fontSize}px" title="${safeName}" aria-label="${safeName}" role="img"><span aria-hidden="true">${esc(initials)}</span>${statusDot}</span>`
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
export function teamAvatarGroup(users = [], maxShow = 3) {
|
|
@@ -82,12 +85,12 @@ export function teamAvatarGroup(users = [], maxShow = 3) {
|
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
export function infoBubble(text, position = 'top') {
|
|
85
|
-
return `<span class="info-bubble info-bubble-${position}" data-tooltip="${text
|
|
88
|
+
return `<span class="info-bubble info-bubble-${position}" data-tooltip="${esc(text)}"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/><text x="8" y="12" text-anchor="middle" fill="currentColor" font-size="10" font-weight="600">i</text></svg></span>`
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
export function sortableList(items = [], containerId = 'sortable') {
|
|
89
92
|
const lis = items.map((item, i) =>
|
|
90
|
-
`<li class="sortable-item" draggable="true" data-index="${i}"><span class="sortable-handle"><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"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></span><span>${typeof item === 'string' ? item : item.label || item.name || ''}</span></li>`
|
|
93
|
+
`<li class="sortable-item" draggable="true" data-index="${i}"><span class="sortable-handle"><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"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></span><span>${esc(typeof item === 'string' ? item : item.label || item.name || '')}</span></li>`
|
|
91
94
|
).join('')
|
|
92
95
|
const script = `(function(){const c=document.getElementById('${containerId}');let dragged=null;c.addEventListener('dragstart',e=>{dragged=e.target.closest('.sortable-item');e.dataTransfer.effectAllowed='move'});c.addEventListener('dragover',e=>{e.preventDefault();const t=e.target.closest('.sortable-item');if(t&&t!==dragged)t.classList.add('drag-over')});c.addEventListener('dragleave',e=>{const t=e.target.closest('.sortable-item');if(t)t.classList.remove('drag-over')});c.addEventListener('drop',e=>{e.preventDefault();const t=e.target.closest('.sortable-item');if(t&&t!==dragged){t.classList.remove('drag-over');c.insertBefore(dragged,t.nextSibling);const order=[...c.querySelectorAll('.sortable-item')].map(el=>+el.dataset.index);c.dispatchEvent(new CustomEvent('sortable-reorder',{detail:{order}}))}});})();`
|
|
93
96
|
return `<ul id="${containerId}" class="sortable-list">${lis}</ul><script>${script}</script>`
|
|
@@ -99,7 +102,7 @@ export function responseChoiceBox(name, options = [], selected = null, type = 'r
|
|
|
99
102
|
const val = typeof opt === 'string' ? opt : opt.value || opt
|
|
100
103
|
const lbl = typeof opt === 'string' ? opt : opt.label || opt.value || opt
|
|
101
104
|
const checked = sel.includes(val) ? 'checked' : ''
|
|
102
|
-
return `<label class="choice-option"><input type="${type}" name="${name}" value="${val}" ${checked}/><span class="choice-label">${lbl}</span></label>`
|
|
105
|
+
return `<label class="choice-option"><input type="${esc(type)}" name="${esc(name)}" value="${esc(val)}" ${checked}/><span class="choice-label">${esc(lbl)}</span></label>`
|
|
103
106
|
}).join('')
|
|
104
107
|
return `<div class="choice-group">${items}</div>`
|
|
105
108
|
}
|
|
@@ -112,8 +115,9 @@ export function responseAttachment(file = {}) {
|
|
|
112
115
|
const isImg = ['png','jpg','jpeg','gif','webp','svg'].includes(ext)
|
|
113
116
|
const isPdf = ext === 'pdf'
|
|
114
117
|
const icon = isImg ? `<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"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>` : isPdf ? `<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="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>` : `<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>`
|
|
115
|
-
const
|
|
116
|
-
|
|
118
|
+
const safeHref = esc(href), safeName = esc(name), safeSize = esc(size)
|
|
119
|
+
const preview = isImg ? `<img src="${safeHref}" alt="${safeName}" class="attachment-preview"/>` : isPdf ? `<iframe src="${safeHref}" class="attachment-preview" style="width:100%;height:200px;border:none"></iframe>` : ''
|
|
120
|
+
return `<div class="attachment-card"><span class="attachment-icon">${icon}</span><div class="attachment-info"><div class="attachment-name">${safeName}</div>${size ? `<div class="attachment-size">${safeSize}</div>` : ''}${preview}</div><a href="${safeHref}" download class="btn btn-ghost btn-xs">Download</a></div>`
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
export function accordion(items) {
|
|
@@ -121,8 +125,9 @@ export function accordion(items) {
|
|
|
121
125
|
return h('div', { className: 'accordion' },
|
|
122
126
|
items.map(item =>
|
|
123
127
|
h('details', { className: 'accordion-item' },
|
|
124
|
-
h('summary', { className: 'accordion-summary' }, item.title),
|
|
125
|
-
|
|
128
|
+
h('summary', { className: 'accordion-summary' }, esc(item.title)),
|
|
129
|
+
// content is a caller-provided HTML fragment by contract.
|
|
130
|
+
h('div', { className: 'accordion-content', innerHTML: item.content })
|
|
126
131
|
)
|
|
127
132
|
).join('')
|
|
128
133
|
)
|
|
@@ -130,7 +135,7 @@ export function accordion(items) {
|
|
|
130
135
|
|
|
131
136
|
export function divider(label) {
|
|
132
137
|
if (!label) return '<hr class="divider"/>'
|
|
133
|
-
return `<div class="divider-labeled"><hr class="divider-line"/><span class="divider-text">${label}</span><hr class="divider-line"/></div>`
|
|
138
|
+
return `<div class="divider-labeled"><hr class="divider-line"/><span class="divider-text">${esc(label)}</span><hr class="divider-line"/></div>`
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
export function responsiveClass(breakpoint) {
|