thatcher 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export function getIdleLogoutScript(config = {}) {
|
|
2
|
+
const idleTimeoutMs = config.idle_timeout_ms || 1800000;
|
|
3
|
+
const idleWarningMs = config.idle_warning_ms || 1500000;
|
|
4
|
+
const warningConfig = config.idle_warning_dialog_config || {};
|
|
5
|
+
|
|
6
|
+
return `
|
|
7
|
+
(function() {
|
|
8
|
+
let idleTimer = null;
|
|
9
|
+
let warningTimer = null;
|
|
10
|
+
let isWarningShown = false;
|
|
11
|
+
|
|
12
|
+
const CONFIG = {
|
|
13
|
+
idleTimeoutMs: ${idleTimeoutMs},
|
|
14
|
+
idleWarningMs: ${idleWarningMs},
|
|
15
|
+
warningTitle: '${warningConfig.title || 'Session Expiring'}',
|
|
16
|
+
warningMessage: '${warningConfig.message || 'Your session will expire due to inactivity'}',
|
|
17
|
+
stayButtonLabel: '${warningConfig.buttons?.[0]?.label || 'Stay Logged In'}',
|
|
18
|
+
logoutButtonLabel: '${warningConfig.buttons?.[1]?.label || 'Logout'}'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function resetIdleTimer() {
|
|
22
|
+
clearTimeout(idleTimer);
|
|
23
|
+
clearTimeout(warningTimer);
|
|
24
|
+
isWarningShown = false;
|
|
25
|
+
removeWarningDialog();
|
|
26
|
+
|
|
27
|
+
warningTimer = setTimeout(() => {
|
|
28
|
+
showWarningDialog();
|
|
29
|
+
}, CONFIG.idleWarningMs);
|
|
30
|
+
|
|
31
|
+
idleTimer = setTimeout(() => {
|
|
32
|
+
logout();
|
|
33
|
+
}, CONFIG.idleTimeoutMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function showWarningDialog() {
|
|
37
|
+
if (isWarningShown) return;
|
|
38
|
+
isWarningShown = true;
|
|
39
|
+
|
|
40
|
+
const dialog = document.createElement('div');
|
|
41
|
+
dialog.id = 'idle-warning-dialog';
|
|
42
|
+
dialog.style.cssText = \`
|
|
43
|
+
position: fixed;
|
|
44
|
+
top: 0;
|
|
45
|
+
left: 0;
|
|
46
|
+
right: 0;
|
|
47
|
+
bottom: 0;
|
|
48
|
+
background: rgba(0,0,0,0.5);
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
z-index: 10000;
|
|
53
|
+
\`;
|
|
54
|
+
|
|
55
|
+
const content = document.createElement('div');
|
|
56
|
+
content.style.cssText = \`
|
|
57
|
+
background: var(--color-surface, #fff);
|
|
58
|
+
padding: 2rem;
|
|
59
|
+
border-radius: 8px;
|
|
60
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
|
61
|
+
max-width: 400px;
|
|
62
|
+
text-align: center;
|
|
63
|
+
color: var(--color-text, #111);
|
|
64
|
+
\`;
|
|
65
|
+
|
|
66
|
+
const title = document.createElement('h3');
|
|
67
|
+
title.textContent = CONFIG.warningTitle;
|
|
68
|
+
title.style.marginBottom = '1rem';
|
|
69
|
+
title.style.color = 'var(--color-text, #333)';
|
|
70
|
+
|
|
71
|
+
const message = document.createElement('p');
|
|
72
|
+
message.textContent = CONFIG.warningMessage;
|
|
73
|
+
message.style.marginBottom = '1.5rem';
|
|
74
|
+
message.style.color = 'var(--color-text-muted, #666)';
|
|
75
|
+
|
|
76
|
+
const buttonContainer = document.createElement('div');
|
|
77
|
+
buttonContainer.style.cssText = 'display: flex; gap: 1rem;';
|
|
78
|
+
|
|
79
|
+
const stayBtn = document.createElement('button');
|
|
80
|
+
stayBtn.textContent = CONFIG.stayButtonLabel;
|
|
81
|
+
stayBtn.style.cssText = \`
|
|
82
|
+
flex: 1;
|
|
83
|
+
padding: 0.75rem;
|
|
84
|
+
background: #007bff;
|
|
85
|
+
color: white;
|
|
86
|
+
border: none;
|
|
87
|
+
border-radius: 4px;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
font-weight: 600;
|
|
90
|
+
\`;
|
|
91
|
+
stayBtn.onclick = () => {
|
|
92
|
+
removeWarningDialog();
|
|
93
|
+
resetIdleTimer();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const logoutBtn = document.createElement('button');
|
|
97
|
+
logoutBtn.textContent = CONFIG.logoutButtonLabel;
|
|
98
|
+
logoutBtn.style.cssText = \`
|
|
99
|
+
flex: 1;
|
|
100
|
+
padding: 0.75rem;
|
|
101
|
+
background: #6c757d;
|
|
102
|
+
color: white;
|
|
103
|
+
border: none;
|
|
104
|
+
border-radius: 4px;
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
font-weight: 600;
|
|
107
|
+
\`;
|
|
108
|
+
logoutBtn.onclick = logout;
|
|
109
|
+
|
|
110
|
+
buttonContainer.appendChild(stayBtn);
|
|
111
|
+
buttonContainer.appendChild(logoutBtn);
|
|
112
|
+
|
|
113
|
+
content.appendChild(title);
|
|
114
|
+
content.appendChild(message);
|
|
115
|
+
content.appendChild(buttonContainer);
|
|
116
|
+
dialog.appendChild(content);
|
|
117
|
+
document.body.appendChild(dialog);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function removeWarningDialog() {
|
|
121
|
+
const dialog = document.getElementById('idle-warning-dialog');
|
|
122
|
+
if (dialog) dialog.remove();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function logout() {
|
|
126
|
+
fetch('/api/auth/logout', { method: 'POST' })
|
|
127
|
+
.then(() => {
|
|
128
|
+
window.location.href = '/login';
|
|
129
|
+
})
|
|
130
|
+
.catch(() => {
|
|
131
|
+
window.location.href = '/login';
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click'];
|
|
136
|
+
|
|
137
|
+
events.forEach(event => {
|
|
138
|
+
document.addEventListener(event, resetIdleTimer, true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
resetIdleTimer();
|
|
142
|
+
|
|
143
|
+
window.idleLogout = { resetIdleTimer, logout };
|
|
144
|
+
})();
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function injectIdleLogoutScript(html, config = {}) {
|
|
149
|
+
const script = getIdleLogoutScript(config);
|
|
150
|
+
const tag = `<script>${script}</script>`;
|
|
151
|
+
|
|
152
|
+
if (html.includes('</body>')) {
|
|
153
|
+
return html.replace('</body>', `${tag}</body>`);
|
|
154
|
+
}
|
|
155
|
+
return html + tag;
|
|
156
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { statusLabel } from '@/ui/renderer.js';
|
|
2
|
+
import { page } from '@/ui/layout.js';
|
|
3
|
+
import { isPartner } from '@/ui/permissions-ui.js';
|
|
4
|
+
|
|
5
|
+
function fmtDate(ts) {
|
|
6
|
+
if (!ts) return '-';
|
|
7
|
+
const n = Number(ts);
|
|
8
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString();
|
|
9
|
+
return String(ts);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function jobStatusBadge(status) {
|
|
13
|
+
const colors = {
|
|
14
|
+
running: { bg: '#dbeafe', text: '#1e40af', icon: '▶' },
|
|
15
|
+
success: { bg: '#d1fae5', text: '#065f46', icon: '✓' },
|
|
16
|
+
failed: { bg: '#fee2e2', text: '#991b1b', icon: '✗' },
|
|
17
|
+
scheduled: { bg: '#fef3c7', text: '#92400e', icon: '⏰' },
|
|
18
|
+
disabled: { bg: '#f3f4f6', text: '#6b7280', icon: '●' },
|
|
19
|
+
};
|
|
20
|
+
const c = colors[status] || colors.scheduled;
|
|
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>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function jobRow(job, canManage) {
|
|
25
|
+
const lastRun = fmtDate(job.last_run_at);
|
|
26
|
+
const nextRun = fmtDate(job.next_run_at);
|
|
27
|
+
const duration = job.last_duration ? (job.last_duration < 1000 ? job.last_duration + 'ms' : Math.round(job.last_duration / 1000) + 's') : '-';
|
|
28
|
+
const triggerBtn = canManage ? `<button class="btn btn-xs btn-primary" data-action="triggerJob" data-args='["${job.name}"]'>Run Now</button>` : '';
|
|
29
|
+
const toggleBtn = canManage ? `<button class="btn btn-xs ${job.enabled !== false ? 'btn-warning' : 'btn-success'}" data-action="toggleJob" data-args='["${job.name}",${!job.enabled}]'>${job.enabled !== false ? 'Disable' : 'Enable'}</button>` : '';
|
|
30
|
+
return `<tr><td class="font-medium">${job.label || job.name}</td><td>${job.schedule || job.cron || '-'}</td><td>${jobStatusBadge(job.status || (job.enabled === false ? 'disabled' : 'scheduled'))}</td><td class="text-sm">${lastRun}</td><td class="text-sm">${nextRun}</td><td class="text-sm">${duration}</td><td class="text-sm">${job.last_result || '-'}</td><td class="flex gap-1">${triggerBtn}${toggleBtn}</td></tr>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderJobManagement(user, jobs, recentLogs = []) {
|
|
34
|
+
const canManage = isPartner(user);
|
|
35
|
+
const runningJobs = jobs.filter(j => j.status === 'running').length;
|
|
36
|
+
const failedJobs = jobs.filter(j => j.status === 'failed').length;
|
|
37
|
+
const totalJobs = jobs.length;
|
|
38
|
+
|
|
39
|
+
const statCards = `<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">${[
|
|
40
|
+
{ label: 'Total Jobs', value: totalJobs },
|
|
41
|
+
{ label: 'Running', value: runningJobs, color: runningJobs > 0 ? 'blue' : '' },
|
|
42
|
+
{ label: 'Failed', value: failedJobs, color: failedJobs > 0 ? 'red' : '' },
|
|
43
|
+
{ label: 'Healthy', value: totalJobs - failedJobs, color: 'green' },
|
|
44
|
+
].map(s => `<div class="card-clean"><div class="card-clean-body" style="padding:0.75rem 0"><h3 class="text-gray-500 text-sm">${s.label}</h3><p class="text-xl font-bold${s.color ? ` text-${s.color}-600` : ''}">${s.value}</p></div></div>`).join('')}</div>`;
|
|
45
|
+
|
|
46
|
+
const headers = '<th>Job</th><th>Schedule</th><th>Status</th><th>Last Run</th><th>Next Run</th><th>Duration</th><th>Result</th><th>Actions</th>';
|
|
47
|
+
const rows = jobs.map(j => jobRow(j, canManage)).join('');
|
|
48
|
+
const table = `<div class="card-clean" style="margin-bottom:1.5rem" style="overflow-x:auto"><div class="card-clean-body"><h3 class="font-semibold mb-3">Scheduled Jobs</h3><table class="data-table"><thead><tr>${headers}</tr></thead><tbody>${rows || '<tr><td colspan="8" class="text-center py-8 text-gray-500">No jobs configured</td></tr>'}</tbody></table></div></div>`;
|
|
49
|
+
|
|
50
|
+
const logRows = recentLogs.slice(0, 20).map(l => {
|
|
51
|
+
const sts = l.success ? '<span class="text-green-600">OK</span>' : '<span class="text-red-600">FAIL</span>';
|
|
52
|
+
return `<tr><td class="text-sm">${l.job_name || '-'}</td><td>${sts}</td><td class="text-xs text-gray-500">${fmtDate(l.started_at)}</td><td class="text-xs">${l.duration ? l.duration + 'ms' : '-'}</td><td class="text-xs text-gray-500 max-w-xs truncate">${l.message || '-'}</td></tr>`;
|
|
53
|
+
}).join('');
|
|
54
|
+
const logTable = `<div class="card-clean"><div class="card-clean-body"><h3 class="font-semibold mb-3">Recent Execution Logs</h3><table class="data-table"><thead><tr><th>Job</th><th>Result</th><th>Time</th><th>Duration</th><th>Message</th></tr></thead><tbody>${logRows || '<tr><td colspan="5" class="text-center py-4 text-gray-400">No recent logs</td></tr>'}</tbody></table></div></div>`;
|
|
55
|
+
|
|
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
|
+
|
|
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)}}`;
|
|
59
|
+
|
|
60
|
+
return page(user, 'Scheduled Jobs | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Jobs' }], content, [jobScript]);
|
|
61
|
+
}
|
package/src/ui/layout.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { h } from '@/ui/webjsx.js'
|
|
2
|
+
import { getNavItems, getAdminItems, isPartner, isClerk } from '@/ui/permissions-ui.js'
|
|
3
|
+
import { TOAST_SCRIPT } from '@/ui/render-helpers.js'
|
|
4
|
+
import { FETCH_JSON_SCRIPT } from '@/ui/fetch-json.js'
|
|
5
|
+
import { aria, role, skipLink, liveRegion } from '@/lib/accessibility'
|
|
6
|
+
|
|
7
|
+
export function generateHtml(title, bodyContent, scripts = [], pathname = '/') {
|
|
8
|
+
const scriptTags = scripts.map(s =>
|
|
9
|
+
typeof s === 'string' ? `<script>${s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--')}</script>` : `<script type="module" src="${s.src}"></script>`
|
|
10
|
+
).join('\n')
|
|
11
|
+
|
|
12
|
+
const swRegistration = `
|
|
13
|
+
if ('serviceWorker' in navigator) {
|
|
14
|
+
window.addEventListener('load', () => {
|
|
15
|
+
navigator.serviceWorker.register('/service-worker.js').catch(() => {});
|
|
16
|
+
});
|
|
17
|
+
}`
|
|
18
|
+
|
|
19
|
+
return `<!DOCTYPE html>
|
|
20
|
+
<html lang="en" data-theme="light">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="UTF-8">
|
|
23
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
24
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
25
|
+
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
|
26
|
+
<link href="/ui/rippleui.css" rel="stylesheet"/>
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
|
|
28
|
+
<meta name="theme-color" content="#04141f">
|
|
29
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
30
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
31
|
+
<title>${title}</title>
|
|
32
|
+
<link rel="preload" href="/ui/client.js" as="script" crossorigin>
|
|
33
|
+
<link rel="preload" href="/ui/styles2.css" as="style">
|
|
34
|
+
<link href="/ui/styles2.css" rel="stylesheet"/>
|
|
35
|
+
<style>
|
|
36
|
+
.skip-link{position:absolute;left:-9999px;z-index:999;padding:0.5rem 1rem;background:#000;color:#fff;text-decoration:none}
|
|
37
|
+
.skip-link:focus{left:0;top:0}
|
|
38
|
+
*:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;border-radius:2px}
|
|
39
|
+
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
${skipLink('#main-content', 'Skip to main content')}
|
|
44
|
+
${skipLink('#main-nav', 'Skip to navigation')}
|
|
45
|
+
${liveRegion('announcements', 'polite')}
|
|
46
|
+
${liveRegion('alerts', 'assertive')}
|
|
47
|
+
<div id="app">${bodyContent}</div>
|
|
48
|
+
<div id="ds-online-status" role="status" aria-live="polite">Offline</div>
|
|
49
|
+
<button id="flexup-fab" type="button" aria-label="Open FlexUp chat" data-action="toggleFlexup" style="display:none">FX</button>
|
|
50
|
+
<div id="flexup-dock" role="dialog" aria-label="FlexUp chat">
|
|
51
|
+
<div class="fx-header">FlexUp Assistant</div>
|
|
52
|
+
<div class="fx-body" id="flexup-body"><div class="fx-msg bot">Ask anything about this review.</div></div>
|
|
53
|
+
<div class="fx-input-row"><input class="fx-input" id="flexup-input" placeholder="Ask a question..." aria-label="Question"/><button class="fx-send" data-action="sendFlexup">Send</button></div>
|
|
54
|
+
</div>
|
|
55
|
+
<script>
|
|
56
|
+
(function(){var el=document.getElementById('ds-online-status');var prev=navigator.onLine;function up(){if(!el)return;var on=navigator.onLine;el.classList.toggle('offline',!on);el.textContent=on?'Online':'Offline';el.style.display=on?'none':'inline-block';if(on!==prev){if(window.showToast)window.showToast(on?'Back online':'You are offline',on?'success':'warning');prev=on}}window.addEventListener('online',up);window.addEventListener('offline',up);up();
|
|
57
|
+
if(/^\\/review\\//.test(location.pathname)){var fab=document.getElementById('flexup-fab');if(fab)fab.style.display='inline-block'}
|
|
58
|
+
window.toggleFlexup=function(){var d=document.getElementById('flexup-dock');if(d)d.classList.toggle('open')};
|
|
59
|
+
window.sendFlexup=async function(){var i=document.getElementById('flexup-input'),b=document.getElementById('flexup-body');if(!i||!b||!i.value.trim())return;var q=i.value.trim();var u=document.createElement('div');u.className='fx-msg user';u.textContent=q;b.appendChild(u);i.value='';var pid=location.pathname.match(/\\/review\\/([^/]+)/);try{var res=await fetch('/api/mwr/ml-ask?review_id='+(pid&&pid[1]||'')+'&q='+encodeURIComponent(q));var data=await res.json();var bot=document.createElement('div');bot.className='fx-msg bot';bot.textContent=(data&&(data.answer||data.message||data.error))||'(no response)';b.appendChild(bot);b.scrollTop=b.scrollHeight}catch(e){var er=document.createElement('div');er.className='fx-msg bot';er.textContent='Error: '+e.message;b.appendChild(er)}};
|
|
60
|
+
})();
|
|
61
|
+
</script>
|
|
62
|
+
<script type="importmap">
|
|
63
|
+
{ "imports": { "webjsx": "/lib/webjsx/index.js", "webjsx/jsx-runtime": "/lib/webjsx/jsx-runtime.js" } }
|
|
64
|
+
</script>
|
|
65
|
+
<script>${FETCH_JSON_SCRIPT}</script>
|
|
66
|
+
<script src="/ui/event-delegation.js"></script>
|
|
67
|
+
<script type="module" src="/ui/client.js"></script>
|
|
68
|
+
${scriptTags}
|
|
69
|
+
<script>${swRegistration}</script>
|
|
70
|
+
</body>
|
|
71
|
+
</html>`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function breadcrumb(items) {
|
|
75
|
+
if (!items?.length) return ''
|
|
76
|
+
return `<nav ${aria.label('Breadcrumb')} class="breadcrumb-clean">${items.map((item, i) =>
|
|
77
|
+
i === items.length - 1
|
|
78
|
+
? `<span ${aria.current('page')}>${item.label}</span>`
|
|
79
|
+
: `<a href="${item.href}">${item.label}</a><span class="breadcrumb-sep" aria-hidden="true">/</span>`
|
|
80
|
+
).join('')}</nav>`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function nav(user, pathname = '') {
|
|
84
|
+
const logoSvg = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>`
|
|
85
|
+
const hamburgerSvg = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>`
|
|
86
|
+
|
|
87
|
+
const isActive = (href) => pathname && (pathname === href || (href !== '/' && pathname.startsWith(href)))
|
|
88
|
+
const linkClass = (href) => isActive(href) ? 'nav-link-active' : 'nav-link'
|
|
89
|
+
|
|
90
|
+
const engLink = !isClerk(user) ? `<a href="/engagements" class="${linkClass('/engagements')}">Engagements</a>` : ''
|
|
91
|
+
const clientLink = !isClerk(user) ? `<a href="/client" class="${linkClass('/client')}">Clients</a>` : ''
|
|
92
|
+
const settingsLink = isPartner(user) ? `<a href="/admin/settings" class="${linkClass('/admin/settings')}">Settings</a>` : ''
|
|
93
|
+
const reviewLink = `<a href="/review" class="${linkClass('/review')}">My Review</a>`
|
|
94
|
+
|
|
95
|
+
const avatarInitial = user?.name?.charAt(0)?.toUpperCase() || 'U'
|
|
96
|
+
const avatarColors = ['#1565c0','#2e7d32','#6a1b9a','#c62828','#e65100','#00695c']
|
|
97
|
+
const avatarBg = avatarColors[(user?.name?.charCodeAt(0) || 0) % avatarColors.length]
|
|
98
|
+
|
|
99
|
+
return `<nav id="main-nav" class="main-nav" ${role.navigation} ${aria.label('Main navigation')}>
|
|
100
|
+
<div class="main-nav-brand">
|
|
101
|
+
<a href="/" class="main-nav-brand-link" ${aria.label('Home')}>
|
|
102
|
+
${logoSvg}
|
|
103
|
+
<span class="main-nav-brand-text">Moonlanding</span>
|
|
104
|
+
</a>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="nav-links-desktop" style="align-items:center;gap:1.25rem;flex-shrink:0">
|
|
107
|
+
${engLink}${clientLink}${settingsLink}${reviewLink}
|
|
108
|
+
</div>
|
|
109
|
+
<div class="main-nav-actions">
|
|
110
|
+
<button id="theme-toggle" type="button" data-action="toggleTheme" aria-label="Toggle dark mode" title="Toggle theme" style="background:none;border:none;color:#ced4da;cursor:pointer;padding:6px;border-radius:4px;display:flex;align-items:center;min-width:32px;min-height:32px">
|
|
111
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
112
|
+
</button>
|
|
113
|
+
<a href="/notifications" id="notif-bell" aria-label="Notifications" title="Notifications" style="position:relative;color:#ced4da;display:flex;align-items:center;padding:6px;border-radius:4px;text-decoration:none">
|
|
114
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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"/></svg>
|
|
115
|
+
<span id="notif-count" style="display:none;position:absolute;top:2px;right:2px;background:#ef4444;color:#fff;border-radius:9999px;font-size:10px;font-weight:700;min-width:16px;height:16px;line-height:16px;text-align:center;padding:0 3px"></span>
|
|
116
|
+
</a>
|
|
117
|
+
<div class="nav-avatar" style="background:${avatarBg}" id="user-avatar" data-action="toggleUserMenu" data-pass-event title="${user?.name || 'User'}" aria-label="User menu" role="button" tabindex="0">
|
|
118
|
+
${avatarInitial}
|
|
119
|
+
</div>
|
|
120
|
+
<button class="nav-hamburger" data-action="toggleMobileNav" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="mobile-nav-drawer" style="background:none;border:none;color:#ced4da;cursor:pointer;padding:8px;border-radius:4px;align-items:center;justify-content:center;min-width:44px;min-height:44px">
|
|
121
|
+
${hamburgerSvg}
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
<div id="user-dropdown" class="user-dropdown" ${role.menu}>
|
|
125
|
+
<div class="user-dropdown-header">
|
|
126
|
+
<div class="user-dropdown-name">${user?.name || ''}</div>
|
|
127
|
+
<div class="user-dropdown-email">${user?.email || ''}</div>
|
|
128
|
+
<div class="user-dropdown-role" style="text-transform:capitalize">${user?.role || ''}</div>
|
|
129
|
+
</div>
|
|
130
|
+
<a href="/api/auth/logout" class="user-dropdown-item">
|
|
131
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
|
132
|
+
Sign out
|
|
133
|
+
</a>
|
|
134
|
+
</div>
|
|
135
|
+
</nav>
|
|
136
|
+
<div id="mobile-nav-drawer" style="display:none;position:fixed;inset:0;z-index:200;pointer-events:none" aria-hidden="true">
|
|
137
|
+
<div data-action="closeMobileNav" style="position:absolute;inset:0;background:rgba(0,0,0,0.5);pointer-events:auto"></div>
|
|
138
|
+
<div style="position:absolute;left:0;top:0;bottom:0;width:280px;max-width:80vw;background:#04141f;padding:0;display:flex;flex-direction:column;transform:translateX(-100%);transition:transform 0.25s ease" id="mobile-nav-panel">
|
|
139
|
+
<div class="mobile-nav-header-bar">
|
|
140
|
+
<span class="mobile-nav-header-title">Moonlanding</span>
|
|
141
|
+
<button data-action="closeMobileNav" class="mobile-nav-close-btn" aria-label="Close menu">×</button>
|
|
142
|
+
</div>
|
|
143
|
+
<nav style="flex:1;padding:0.5rem 0;overflow-y:auto">
|
|
144
|
+
<a href="/" class="mobile-nav-link">Dashboard</a>
|
|
145
|
+
${!isClerk(user) ? `<a href="/engagements" class="mobile-nav-link">Engagements</a>` : ''}
|
|
146
|
+
${!isClerk(user) ? `<a href="/client" class="mobile-nav-link">Clients</a>` : ''}
|
|
147
|
+
<a href="/review" class="mobile-nav-link">My Review</a>
|
|
148
|
+
${isPartner(user) ? `<a href="/admin/settings" class="mobile-nav-link">Settings</a>` : ''}
|
|
149
|
+
</nav>
|
|
150
|
+
<div class="mobile-nav-footer">
|
|
151
|
+
<div class="mobile-nav-user-name">${user?.name || ''}</div>
|
|
152
|
+
<div class="mobile-nav-user-email">${user?.email || ''}</div>
|
|
153
|
+
<a href="/api/auth/logout" class="mobile-nav-logout">Sign out</a>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div id="idle-warning-dialog" class="confirm-overlay" style="display:none" ${role.alertdialog} ${aria.labelledBy('idle-dialog-title')} ${aria.describedBy('idle-dialog-msg')} ${aria.hidden('true')}>
|
|
158
|
+
<div class="confirm-dialog">
|
|
159
|
+
<h2 id="idle-dialog-title" class="confirm-title">Session Expiring</h2>
|
|
160
|
+
<div id="idle-dialog-msg" class="confirm-message">Your session will expire due to inactivity. You will be logged out in <span id="idle-countdown" ${aria.live('polite')}>5:00</span>.</div>
|
|
161
|
+
<div class="confirm-actions"><button type="button" data-action="stayLoggedIn" class="btn btn-primary" ${aria.label('Stay logged in and reset timeout')}>Stay Logged In</button></div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<script>
|
|
165
|
+
window.loadingBtn=function(btn,loading,label){if(!btn)return;btn.disabled=loading;btn.style.opacity=loading?'0.7':'1';if(loading){btn._orig=btn.innerHTML;btn.innerHTML='<span class="btn-spinner"></span>'+(label||'Loading...');}else{btn.innerHTML=btn._orig||label||btn.innerHTML;btn.disabled=false}}
|
|
166
|
+
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';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)}
|
|
167
|
+
window.toggleTheme=function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var next=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',next);try{localStorage.setItem('moonlanding-theme',next)}catch(_){}}
|
|
168
|
+
;(function(){try{var saved=localStorage.getItem('moonlanding-theme');if(saved){document.documentElement.setAttribute('data-theme',saved)}else if(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches){document.documentElement.setAttribute('data-theme','dark')}}catch(_){}})();
|
|
169
|
+
function toggleUserMenu(e){e.stopPropagation();var d=document.getElementById('user-dropdown');var visible=d.style.display==='block';d.style.display=visible?'none':'block'}
|
|
170
|
+
document.addEventListener('click',function(e){var d=document.getElementById('user-dropdown');if(d&&!d.contains(e.target)&&e.target.id!=='user-avatar'){d.style.display='none'}})
|
|
171
|
+
function toggleMobileNav(){var btn=document.querySelector('.nav-hamburger');var open=btn.getAttribute('aria-expanded')==='true';btn.setAttribute('aria-expanded',String(!open));var drawer=document.getElementById('mobile-nav-drawer');var panel=document.getElementById('mobile-nav-panel');drawer.style.display=open?'none':'block';drawer.setAttribute('aria-hidden',String(open));panel.style.transform=open?'translateX(-100%)':'translateX(0)'}
|
|
172
|
+
function closeMobileNav(){var btn=document.querySelector('.nav-hamburger');if(btn)btn.setAttribute('aria-expanded','false');var drawer=document.getElementById('mobile-nav-drawer');var panel=document.getElementById('mobile-nav-panel');if(drawer){drawer.style.display='none';drawer.setAttribute('aria-hidden','true');if(panel)panel.style.transform='translateX(-100%)'}}
|
|
173
|
+
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeMobileNav()})
|
|
174
|
+
;(function(){var WARN_MS=25*60*1000,LOGOUT_MS=30*60*1000,last=Date.now(),warnTimer=null,logoutTimer=null,countdownId=null;
|
|
175
|
+
function reset(){last=Date.now();hideWarning();clearTimers();schedule()}
|
|
176
|
+
function schedule(){warnTimer=setTimeout(showWarning,WARN_MS);logoutTimer=setTimeout(doLogout,LOGOUT_MS)}
|
|
177
|
+
function clearTimers(){if(warnTimer){clearTimeout(warnTimer);warnTimer=null}if(logoutTimer){clearTimeout(logoutTimer);logoutTimer=null}if(countdownId){clearInterval(countdownId);countdownId=null}}
|
|
178
|
+
function showWarning(){var dlg=document.getElementById('idle-warning-dialog');if(dlg)dlg.style.display='flex';var remaining=LOGOUT_MS-(Date.now()-last);countdownId=setInterval(function(){remaining-=1000;if(remaining<=0){clearInterval(countdownId);doLogout();return}var m=Math.floor(remaining/60000),s=Math.floor((remaining%60000)/1000);var el=document.getElementById('idle-countdown');if(el)el.textContent=m+':'+(s<10?'0':'')+s},1000)}
|
|
179
|
+
function hideWarning(){var dlg=document.getElementById('idle-warning-dialog');if(dlg)dlg.style.display='none'}
|
|
180
|
+
function doLogout(){clearTimers();window.location.href='/api/auth/logout'}
|
|
181
|
+
window.stayLoggedIn=function(){fetch('/api/auth/me',{credentials:'same-origin'}).catch(function(){});reset()};
|
|
182
|
+
['mousemove','keypress','click','scroll','touchstart'].forEach(function(evt){document.addEventListener(evt,function(){var now=Date.now();if(now-last>60000){last=now;clearTimers();schedule()}else{last=now}},true)});
|
|
183
|
+
schedule()})();
|
|
184
|
+
</script>`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const NOTIF_SCRIPT = `(function(){function loadNotifCount(){fetch('/api/notifications?unread=1',{credentials:'same-origin'}).then(function(r){return r.json()}).then(function(d){var n=(d.data||[]).length;var el=document.getElementById('notif-count');if(!el)return;if(n>0){el.textContent=n>99?'99+':n;el.style.display='block'}else{el.style.display='none'}}).catch(function(){})}loadNotifCount();setInterval(loadNotifCount,60000)})();`;
|
|
188
|
+
|
|
189
|
+
export function page(user, title, bc, content, scripts = []) {
|
|
190
|
+
const authData = user ? JSON.stringify({ id: user.id, name: user.name, email: user.email, role: user.role }) : 'null'
|
|
191
|
+
const authScript = `window.__AUTH__=${authData};`
|
|
192
|
+
const body = `<div class="min-h-screen">${nav(user)}<main id="main-content" ${role.main} class="page-shell">${breadcrumb(bc)}${content}</main></div>`
|
|
193
|
+
return generateHtml(title, body, [authScript, NOTIF_SCRIPT, ...scripts])
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function fullPage(user, title, content, scripts = []) {
|
|
197
|
+
const authData = user ? JSON.stringify({ id: user.id, name: user.name, email: user.email, role: user.role }) : 'null'
|
|
198
|
+
const authScript = `window.__AUTH__=${authData};`
|
|
199
|
+
const body = `<div class="min-h-screen flex flex-col">${nav(user)}<main id="main-content" ${role.main} class="page-shell-flush flex-1 flex flex-col">${content}</main></div>`
|
|
200
|
+
return generateHtml(title, body, [authScript, ...scripts])
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function statCards(cards) {
|
|
204
|
+
return `<div class="stats shadow w-full mb-6 flex-wrap">` +
|
|
205
|
+
cards.map(c => `<div class="stat"><div class="stat-title">${c.label}</div><div class="stat-value text-2xl${c.textClass || ''}">${c.value}</div>${c.sub ? `<div class="stat-desc">${c.sub}</div>` : ''}</div>`).join('') +
|
|
206
|
+
`</div>`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function confirmDialog(entityName) {
|
|
210
|
+
return `<div id="confirm-dialog" class="confirm-overlay" style="display:none" ${role.alertdialog} ${aria.labelledBy('confirm-title')} ${aria.describedBy('confirm-msg')} ${aria.hidden('true')}>
|
|
211
|
+
<div class="confirm-dialog"><h2 id="confirm-title" class="confirm-title">Confirm Delete</h2>
|
|
212
|
+
<div id="confirm-msg" class="confirm-message">Are you sure you want to delete this item? This action cannot be undone.</div>
|
|
213
|
+
<div class="confirm-actions"><button type="button" data-action="cancelDelete" class="btn btn-ghost" ${aria.label('Cancel deletion')}>Cancel</button>
|
|
214
|
+
<button type="button" id="confirm-delete-btn" data-action="executeDelete" class="btn btn-error" ${aria.label('Confirm deletion')}>Delete</button></div></div></div>`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function dataTable(headers, rows, emptyMsg) {
|
|
218
|
+
return `<div class="card-clean"><div class="table-wrap" ${role.region} ${aria.label('Data table')}><table class="data-table"><thead><tr>${headers}</tr></thead><tbody id="table-body">${rows || `<tr><td colspan="100" class="text-center py-8 text-base-content/50">${emptyMsg}</td></tr>`}</tbody></table></div></div>`
|
|
219
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function engagementLetterDialog(engagementId) {
|
|
2
|
+
return `<div id="eng-letter-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="eng-letter-dialog-title" aria-hidden="true">
|
|
3
|
+
<div class="dialog-panel" style="max-width:640px">
|
|
4
|
+
<div class="dialog-header"><span class="dialog-title" id="eng-letter-dialog-title">Engagement Letter</span><button class="dialog-close" data-dialog-close="eng-letter-dialog" aria-label="Close dialog">×</button></div>
|
|
5
|
+
<div class="dialog-body">
|
|
6
|
+
<div class="modal-form-group"><label for="eld-template">Template</label><select id="eld-template" class="select select-bordered w-full" onchange="eldLoadTemplate()"><option value="">Select template...</option></select></div>
|
|
7
|
+
<div class="modal-form-group"><label for="eld-date">Letter Date</label><input type="date" id="eld-date" class="input input-bordered w-full"/></div>
|
|
8
|
+
<div class="modal-form-group"><label for="eld-recipient">Recipient Name</label><input id="eld-recipient" class="input input-bordered w-full" placeholder="Client contact name"/></div>
|
|
9
|
+
<div class="modal-form-group"><label for="eld-content">Content</label><textarea id="eld-content" class="textarea textarea-bordered w-full" rows="8" placeholder="Letter content..."></textarea></div>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="eng-letter-dialog">Cancel</button><button class="btn btn-outline btn-sm" data-action="eldPreview">Preview</button><button class="btn btn-primary btn-sm" data-action="eldSave">Save Letter</button></div>
|
|
12
|
+
</div></div>
|
|
13
|
+
<script>
|
|
14
|
+
(function(){fetch('/api/letter_template').then(function(r){return r.json()}).then(function(d){var sel=document.getElementById('eld-template');(d.data||d||[]).forEach(function(t){var o=document.createElement('option');o.value=t.id;o.textContent=t.name||t.id;sel.appendChild(o)})}).catch(function(){})})();
|
|
15
|
+
window.openEngLetter=function(){document.getElementById('eng-letter-dialog').style.display='flex'};
|
|
16
|
+
window.eldLoadTemplate=function(){var id=document.getElementById('eld-template').value;if(!id)return;fetch('/api/letter_template/'+id).then(function(r){return r.json()}).then(function(d){var t=d.data||d;document.getElementById('eld-content').value=t.content||t.body||''}).catch(function(){})};
|
|
17
|
+
window.eldPreview=function(){var content=document.getElementById('eld-content').value;var w=window.open('','_blank','width=800,height=600');w.document.write('<html><body style="padding:2rem;font-family:serif;max-width:700px;margin:auto"><h2>Engagement Letter</h2><p>Date: '+(document.getElementById('eld-date').value||'-')+'</p><p>To: '+(document.getElementById('eld-recipient').value||'-')+'</p><hr/><div style="white-space:pre-wrap">'+content+'</div></body></html>');w.document.close()};
|
|
18
|
+
window.eldSave=async function(){var body={engagement_id:'${engagementId}',template_id:document.getElementById('eld-template').value||null,date:document.getElementById('eld-date').value||null,recipient:document.getElementById('eld-recipient').value||null,content:document.getElementById('eld-content').value};try{var r=await fetch('/api/letter',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(r.ok){showToast('Letter saved','success');document.getElementById('eng-letter-dialog').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
19
|
+
</script>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function engagementLetterTemplateUpload() {
|
|
23
|
+
return `<div id="letter-tpl-upload" 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="letter-tpl-upload-title" aria-hidden="true">
|
|
24
|
+
<div class="dialog-panel">
|
|
25
|
+
<div class="dialog-header"><span class="dialog-title" id="letter-tpl-upload-title">Upload Letter Template</span><button class="dialog-close" data-dialog-close="letter-tpl-upload" aria-label="Close dialog">×</button></div>
|
|
26
|
+
<div class="dialog-body">
|
|
27
|
+
<div class="modal-form-group"><label for="ltu-name">Template Name</label><input id="ltu-name" class="input input-bordered w-full" placeholder="Template name"/></div>
|
|
28
|
+
<div class="modal-form-group"><label for="ltu-file">Template File</label><input type="file" id="ltu-file" class="file-input file-input-bordered w-full" accept=".docx,.doc,.html,.txt"/></div>
|
|
29
|
+
<div class="modal-form-group"><label for="ltu-content">Or paste content</label><textarea id="ltu-content" class="textarea textarea-bordered w-full" rows="5" placeholder="Template content..."></textarea></div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="letter-tpl-upload">Cancel</button><button class="btn btn-primary btn-sm" data-action="ltuSave">Save Template</button></div>
|
|
32
|
+
</div></div>
|
|
33
|
+
<script>
|
|
34
|
+
window.openLetterTemplateUpload=function(){document.getElementById('letter-tpl-upload').style.display='flex'};
|
|
35
|
+
window.ltuSave=async function(){var name=document.getElementById('ltu-name').value.trim();if(!name){showToast('Name required','error');return}var file=document.getElementById('ltu-file').files[0];var content=document.getElementById('ltu-content').value;if(file){var fd=new FormData();fd.append('file',file);fd.append('name',name);try{var r=await fetch('/api/letter_template/upload',{method:'POST',body:fd});if(r.ok){showToast('Template uploaded','success');document.getElementById('letter-tpl-upload').style.display='none';location.reload()}else showToast('Upload failed','error')}catch(e){showToast('Error','error')}}else if(content){try{var r=await fetch('/api/letter_template',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,content:content})});if(r.ok){showToast('Template saved','success');document.getElementById('letter-tpl-upload').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}}else{showToast('Provide file or content','error')}};
|
|
36
|
+
</script>`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ML / AI query console — lists highlight comments flagged as ML-candidate queries.
|
|
2
|
+
// MVP: read-only list of highlights with comment text + link back to the source review.
|
|
3
|
+
// Full AI integration (model invocation, response streaming) is out of scope — stub.
|
|
4
|
+
|
|
5
|
+
import { page } from '@/ui/layout.js';
|
|
6
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
7
|
+
import { SPACING } from '@/ui/spacing-system.js';
|
|
8
|
+
|
|
9
|
+
function fmtDate(ts) {
|
|
10
|
+
if (!ts) return '-';
|
|
11
|
+
const n = typeof ts === 'number' ? (ts > 1e10 ? ts : ts * 1000) : Date.parse(ts);
|
|
12
|
+
if (!n || isNaN(n)) return '-';
|
|
13
|
+
return new Date(n).toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderMlConsole(user, candidates, reviewMap) {
|
|
17
|
+
const rows = candidates.map((h) => {
|
|
18
|
+
const reviewName = reviewMap[h.review_id] || '-';
|
|
19
|
+
const comment = h.comment || h.text || '';
|
|
20
|
+
const truncated = comment.length > 240 ? comment.slice(0, 240) + '…' : comment;
|
|
21
|
+
return `<tr>
|
|
22
|
+
<td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:13px">
|
|
23
|
+
<div style="font-weight:500">${esc(reviewName)}</div>
|
|
24
|
+
<div style="font-size:11px;color:var(--color-text-muted);margin-top:2px">p.${h.page_number || '?'}</div>
|
|
25
|
+
</td>
|
|
26
|
+
<td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:13px;max-width:560px">${esc(truncated)}</td>
|
|
27
|
+
<td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:12px;color:var(--color-text-muted);white-space:nowrap">${fmtDate(h.created_at)}</td>
|
|
28
|
+
<td style="padding:8px 10px;border-bottom:1px solid var(--color-border);text-align:right">
|
|
29
|
+
<a href="/review/${esc(h.review_id)}/pdf#h-${esc(h.id)}" class="btn-ghost-clean" style="padding:4px 10px;font-size:12px">Open</a>
|
|
30
|
+
<button data-action="runMlQuery" data-args='["${esc(h.id)}"]' class="btn-primary-clean" style="padding:4px 10px;font-size:12px;margin-left:4px">Ask AI</button>
|
|
31
|
+
</td>
|
|
32
|
+
</tr>`;
|
|
33
|
+
}).join('') || `<tr><td colspan="4" style="padding:48px;text-align:center;color:var(--color-text-muted);font-size:13px">
|
|
34
|
+
No highlights have been flagged as ML-query candidates yet.<br>
|
|
35
|
+
<span style="font-size:12px">Mark a highlight with the ML flag to see it here.</span>
|
|
36
|
+
</td></tr>`;
|
|
37
|
+
|
|
38
|
+
const content = `<div style="margin:0 auto;max-width:1100px;padding:${SPACING.lg}">
|
|
39
|
+
<div class="page-header">
|
|
40
|
+
<div>
|
|
41
|
+
<h1 class="page-title">AI / ML Query Console</h1>
|
|
42
|
+
<div style="font-size:13px;color:var(--color-text-muted);margin-top:4px">
|
|
43
|
+
${candidates.length} flagged highlight${candidates.length === 1 ? '' : 's'} pending AI assistance.
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div style="background:var(--color-surface,white);border:1px solid var(--color-border);border-radius:8px;overflow:hidden;margin-top:${SPACING.md}">
|
|
48
|
+
<table style="width:100%;border-collapse:collapse">
|
|
49
|
+
<thead>
|
|
50
|
+
<tr style="background:var(--color-surface-muted,#f8fafc)">
|
|
51
|
+
<th style="padding:10px;text-align:left;font-size:12px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px">Review</th>
|
|
52
|
+
<th style="padding:10px;text-align:left;font-size:12px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px">Comment</th>
|
|
53
|
+
<th style="padding:10px;text-align:left;font-size:12px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px">Created</th>
|
|
54
|
+
<th style="padding:10px;text-align:right;font-size:12px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.5px">Actions</th>
|
|
55
|
+
</tr>
|
|
56
|
+
</thead>
|
|
57
|
+
<tbody>${rows}</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
</div>
|
|
60
|
+
<div style="margin-top:${SPACING.md};padding:${SPACING.md};background:var(--color-surface-muted,#f8fafc);border-radius:8px;font-size:13px;color:var(--color-text-muted)">
|
|
61
|
+
<strong>Note:</strong> Full AI integration requires an external model service.
|
|
62
|
+
Clicking "Ask AI" currently records an intent; plumbing to a model endpoint is
|
|
63
|
+
the next implementation step.
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div id="ml-ask-dialog" class="modal" style="display:none" data-dialog-close-overlay="true">
|
|
67
|
+
<div class="modal-overlay" data-dialog-close="ml-ask-dialog"></div>
|
|
68
|
+
<div class="modal-content rounded-box max-w-lg p-6">
|
|
69
|
+
<h3 style="font-size:16px;font-weight:600;margin:0 0 12px 0">AI Suggestion</h3>
|
|
70
|
+
<div id="ml-ask-query" style="font-size:13px;color:var(--color-text-muted);margin-bottom:12px;padding:8px 10px;background:var(--color-surface-muted,#f8fafc);border-radius:6px"></div>
|
|
71
|
+
<div id="ml-ask-result" style="font-size:14px;white-space:pre-wrap;min-height:60px;padding:10px;background:white;border:1px solid var(--color-border);border-radius:6px">
|
|
72
|
+
<div style="color:var(--color-text-muted);font-style:italic">Generating suggestion…</div>
|
|
73
|
+
</div>
|
|
74
|
+
<div id="ml-ask-meta" style="font-size:11px;color:var(--color-text-muted);margin-top:8px"></div>
|
|
75
|
+
<div class="modal-action" style="margin-top:16px">
|
|
76
|
+
<button data-dialog-close="ml-ask-dialog" class="btn btn-ghost">Close</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<script>
|
|
81
|
+
(function(){
|
|
82
|
+
window.runMlQuery = async function(highlightId){
|
|
83
|
+
var row = document.querySelector('[data-args=\\'["'+highlightId+'"]\\']').closest('tr');
|
|
84
|
+
var qText = row ? row.querySelector('td:nth-child(2)').textContent.trim() : '';
|
|
85
|
+
var dlg = document.getElementById('ml-ask-dialog');
|
|
86
|
+
document.getElementById('ml-ask-query').textContent = qText;
|
|
87
|
+
document.getElementById('ml-ask-result').innerHTML = '<div style="color:#64748b;font-style:italic">Generating suggestion…</div>';
|
|
88
|
+
document.getElementById('ml-ask-meta').textContent = '';
|
|
89
|
+
dlg.style.display = 'flex';
|
|
90
|
+
try {
|
|
91
|
+
var r = await fetch('/api/mwr/ml-ask', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify({ highlight_id: highlightId }) });
|
|
92
|
+
var d = await r.json();
|
|
93
|
+
if (d.success) {
|
|
94
|
+
document.getElementById('ml-ask-result').textContent = d.suggestion || '(empty)';
|
|
95
|
+
document.getElementById('ml-ask-meta').textContent = d.mode === 'live' ? ('Model: ' + (d.model || '?') + (d.usage ? ' · ' + (d.usage.input_tokens||0) + ' in / ' + (d.usage.output_tokens||0) + ' out' : '')) : ('Mode: ' + (d.mode || 'stub'));
|
|
96
|
+
} else {
|
|
97
|
+
document.getElementById('ml-ask-result').textContent = 'Error: ' + (d.error || 'unknown');
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
document.getElementById('ml-ask-result').textContent = 'Network error: ' + e.message;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
})();
|
|
104
|
+
</script>`;
|
|
105
|
+
|
|
106
|
+
const bc = [{ href: '/', label: 'Home' }, { href: '/review', label: 'Reviews' }, { label: 'ML Console' }];
|
|
107
|
+
return page(user, 'AI / ML Query Console | Moonlanding', bc, content);
|
|
108
|
+
}
|