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.
Files changed (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/package.json +73 -0
  4. package/src/adapters/google-auth.js +148 -0
  5. package/src/adapters/google-drive.js +209 -0
  6. package/src/app/api/[entity]/[[...path]]/route.js +33 -0
  7. package/src/app/api/audit/dashboard/route.js +77 -0
  8. package/src/app/api/audit/logs/route.js +46 -0
  9. package/src/app/api/audit/permissions/[id]/route.js +37 -0
  10. package/src/app/api/audit/permissions/route.js +93 -0
  11. package/src/app/api/audit/permissions/stats/route.js +29 -0
  12. package/src/app/api/audit/route.js +79 -0
  13. package/src/app/api/audit/stats/route.js +18 -0
  14. package/src/app/api/auth/google/callback/route.js +94 -0
  15. package/src/app/api/auth/google/route.js +58 -0
  16. package/src/app/api/auth/login/route.js +121 -0
  17. package/src/app/api/auth/logout/route.js +52 -0
  18. package/src/app/api/auth/me/route.js +16 -0
  19. package/src/app/api/auth/mwr-bridge/route.js +77 -0
  20. package/src/app/api/auth/password-reset/route.js +65 -0
  21. package/src/app/api/cron/trigger/route.js +58 -0
  22. package/src/app/api/csrf-token/route.js +8 -0
  23. package/src/app/api/debug/config/route.js +22 -0
  24. package/src/app/api/debug/hooks/route.js +16 -0
  25. package/src/app/api/debug/plugins/route.js +20 -0
  26. package/src/app/api/debug/sqlite/route.js +21 -0
  27. package/src/app/api/debug/sync/route.js +29 -0
  28. package/src/app/api/debug/workflow/route.js +20 -0
  29. package/src/app/api/domains/[domain]/route.js +26 -0
  30. package/src/app/api/domains/route.js +21 -0
  31. package/src/app/api/email/allocate/batch/route.js +106 -0
  32. package/src/app/api/email/allocate/route.js +141 -0
  33. package/src/app/api/email/receive/route.js +158 -0
  34. package/src/app/api/email/route.js +3 -0
  35. package/src/app/api/email/send/route.js +77 -0
  36. package/src/app/api/email/unallocated/route.js +47 -0
  37. package/src/app/api/files/[id]/route.js +38 -0
  38. package/src/app/api/health/route.js +95 -0
  39. package/src/app/api/metrics/route.js +73 -0
  40. package/src/app/api/monitoring/dashboard/route.js +18 -0
  41. package/src/cli.js +243 -0
  42. package/src/config/config-loader.js +112 -0
  43. package/src/config/constants.js +127 -0
  44. package/src/config/env.js +164 -0
  45. package/src/config/spec-helpers.js +232 -0
  46. package/src/engine.server.js +212 -0
  47. package/src/index.js +368 -0
  48. package/src/lib/accessibility.js +162 -0
  49. package/src/lib/action-factory.js +34 -0
  50. package/src/lib/action-utils.js +21 -0
  51. package/src/lib/alert-manager.js +189 -0
  52. package/src/lib/api-error-wrapper.js +125 -0
  53. package/src/lib/api-helpers.js +53 -0
  54. package/src/lib/api.js +82 -0
  55. package/src/lib/audit-logger-enhanced.js +117 -0
  56. package/src/lib/audit-logger.js +193 -0
  57. package/src/lib/auth-middleware.js +102 -0
  58. package/src/lib/auth-route-helpers.js +83 -0
  59. package/src/lib/business-rules-engine.js +86 -0
  60. package/src/lib/compression.js +44 -0
  61. package/src/lib/config-field-helpers.js +91 -0
  62. package/src/lib/config-generator-engine.js +445 -0
  63. package/src/lib/config-helpers.js +120 -0
  64. package/src/lib/connection-guard.js +79 -0
  65. package/src/lib/crud-action-helpers.js +34 -0
  66. package/src/lib/crud-factory.js +83 -0
  67. package/src/lib/crud-handlers.js +244 -0
  68. package/src/lib/csrf-protection.js +63 -0
  69. package/src/lib/database-core.js +258 -0
  70. package/src/lib/database-migrations.js +96 -0
  71. package/src/lib/date-utils.js +159 -0
  72. package/src/lib/db-backup.js +97 -0
  73. package/src/lib/db-monitor.js +127 -0
  74. package/src/lib/domain-loader.js +82 -0
  75. package/src/lib/email-sender.js +100 -0
  76. package/src/lib/error-boundary.js +134 -0
  77. package/src/lib/error-handler.js +84 -0
  78. package/src/lib/error-recovery.js +190 -0
  79. package/src/lib/error-resilience.js +130 -0
  80. package/src/lib/errors.js +69 -0
  81. package/src/lib/events-engine.js +182 -0
  82. package/src/lib/field-iterator.js +50 -0
  83. package/src/lib/field-registry.js +68 -0
  84. package/src/lib/field-types.js +154 -0
  85. package/src/lib/generic-crud-handler.js +32 -0
  86. package/src/lib/health-monitor.js +134 -0
  87. package/src/lib/hook-engine.js +169 -0
  88. package/src/lib/hot-reload/cache-invalidator.js +115 -0
  89. package/src/lib/hot-reload/checkpoint.js +95 -0
  90. package/src/lib/hot-reload/debug-exposure.js +67 -0
  91. package/src/lib/hot-reload/directory-watcher.js +96 -0
  92. package/src/lib/hot-reload/index.js +50 -0
  93. package/src/lib/hot-reload/mutex.js +75 -0
  94. package/src/lib/hot-reload/promise-container.js +66 -0
  95. package/src/lib/hot-reload/route-wrapper.js +46 -0
  96. package/src/lib/hot-reload/safe-error.js +51 -0
  97. package/src/lib/hot-reload/supervisor.js +161 -0
  98. package/src/lib/hot-reload/timeout-wrapper.js +52 -0
  99. package/src/lib/http-methods-factory.js +25 -0
  100. package/src/lib/index-optimizer.js +96 -0
  101. package/src/lib/index.js +35 -0
  102. package/src/lib/list-data-transform.js +39 -0
  103. package/src/lib/log-aggregator.js +116 -0
  104. package/src/lib/logger.js +55 -0
  105. package/src/lib/metrics-collector.js +102 -0
  106. package/src/lib/minifier.js +19 -0
  107. package/src/lib/monitoring-init.js +67 -0
  108. package/src/lib/next-compat.js +80 -0
  109. package/src/lib/next-polyfills.js +135 -0
  110. package/src/lib/perf-monitor.js +91 -0
  111. package/src/lib/progress-components.js +181 -0
  112. package/src/lib/query-cache.js +126 -0
  113. package/src/lib/query-engine-write.js +221 -0
  114. package/src/lib/query-engine.js +399 -0
  115. package/src/lib/query-perf.js +117 -0
  116. package/src/lib/query-string-adapter.js +75 -0
  117. package/src/lib/realtime-server.js +67 -0
  118. package/src/lib/render-cache.js +61 -0
  119. package/src/lib/request-tracker.js +43 -0
  120. package/src/lib/resource-hints.js +29 -0
  121. package/src/lib/resource-monitor.js +117 -0
  122. package/src/lib/response-formatter.js +80 -0
  123. package/src/lib/route-helpers.js +33 -0
  124. package/src/lib/route-resolver.js +142 -0
  125. package/src/lib/safe-json.js +8 -0
  126. package/src/lib/server-bootstrap.js +71 -0
  127. package/src/lib/stage-pipeline.js +153 -0
  128. package/src/lib/state-protocol.js +171 -0
  129. package/src/lib/state-transport-client.js +169 -0
  130. package/src/lib/state-transport-reconnect.js +121 -0
  131. package/src/lib/state-transport-server.js +181 -0
  132. package/src/lib/static-server.js +97 -0
  133. package/src/lib/status-helpers.js +98 -0
  134. package/src/lib/universal-handler.js +7 -0
  135. package/src/lib/utils.js +93 -0
  136. package/src/lib/validate.js +197 -0
  137. package/src/lib/validation/business-validators.js +61 -0
  138. package/src/lib/validation/csrf.js +51 -0
  139. package/src/lib/validation/file-validators.js +34 -0
  140. package/src/lib/validation/format-validators.js +106 -0
  141. package/src/lib/validation/index.js +19 -0
  142. package/src/lib/validation/rate-limit.js +31 -0
  143. package/src/lib/validation/security-validators.js +78 -0
  144. package/src/lib/validation-middleware.js +133 -0
  145. package/src/lib/validators.js +105 -0
  146. package/src/lib/with-audit-logging.js +63 -0
  147. package/src/lib/with-error-handler.js +31 -0
  148. package/src/lib/workflow-engine.js +250 -0
  149. package/src/server/server.js +305 -0
  150. package/src/services/collaborator-role.service.js +205 -0
  151. package/src/services/email-sender.js +105 -0
  152. package/src/services/notification-engine.js +110 -0
  153. package/src/services/permission.service.js +181 -0
  154. package/src/ui/advanced-search-renderer.js +42 -0
  155. package/src/ui/advanced-widgets.js +47 -0
  156. package/src/ui/auth-pages.js +114 -0
  157. package/src/ui/auth-styles.js +53 -0
  158. package/src/ui/client.js +99 -0
  159. package/src/ui/collaboration-dialogs.js +31 -0
  160. package/src/ui/common-handlers.js +156 -0
  161. package/src/ui/component-engine.js +103 -0
  162. package/src/ui/dashboard-renderer.js +150 -0
  163. package/src/ui/dialog-engine.js +147 -0
  164. package/src/ui/dialog-factory.js +38 -0
  165. package/src/ui/engagement-cards.js +76 -0
  166. package/src/ui/engagement-dialogs.js +100 -0
  167. package/src/ui/engagement-grid-renderer.js +109 -0
  168. package/src/ui/entity-renderer.js +176 -0
  169. package/src/ui/event-delegation.js +108 -0
  170. package/src/ui/fetch-json.js +24 -0
  171. package/src/ui/file-dialogs.js +81 -0
  172. package/src/ui/flexup-report-renderer.js +173 -0
  173. package/src/ui/format-helpers.js +102 -0
  174. package/src/ui/global-tags.js +144 -0
  175. package/src/ui/highlight-threading-renderer.js +176 -0
  176. package/src/ui/idle-logout.js +156 -0
  177. package/src/ui/job-management-renderer.js +61 -0
  178. package/src/ui/layout.js +219 -0
  179. package/src/ui/letter-dialogs.js +37 -0
  180. package/src/ui/ml-console-renderer.js +108 -0
  181. package/src/ui/monitoring-dashboard-client.js +136 -0
  182. package/src/ui/monitoring-dashboard.js +134 -0
  183. package/src/ui/notifications-renderer.js +51 -0
  184. package/src/ui/page-handler-admin.js +121 -0
  185. package/src/ui/page-handler-helpers.js +111 -0
  186. package/src/ui/page-handler-reviews.js +165 -0
  187. package/src/ui/page-handler-rfi.js +42 -0
  188. package/src/ui/page-handler.js +210 -0
  189. package/src/ui/password-reset-page.js +146 -0
  190. package/src/ui/perf-helpers.js +96 -0
  191. package/src/ui/perf-renderer.js +71 -0
  192. package/src/ui/permissions-ui.js +163 -0
  193. package/src/ui/picker-dialogs.js +100 -0
  194. package/src/ui/render-helpers.js +124 -0
  195. package/src/ui/renderer.js +35 -0
  196. package/src/ui/review-comparison-renderer.js +58 -0
  197. package/src/ui/review-detail-panels.js +71 -0
  198. package/src/ui/review-detail-renderer.js +170 -0
  199. package/src/ui/review-detail-script.js +95 -0
  200. package/src/ui/review-mwr-renderer.js +113 -0
  201. package/src/ui/review-renderer.js +202 -0
  202. package/src/ui/review-widgets.js +88 -0
  203. package/src/ui/review-zone-nav.js +12 -0
  204. package/src/ui/rfi-detail-renderer.js +191 -0
  205. package/src/ui/rfi-renderer.js +194 -0
  206. package/src/ui/rfi-report-renderer.js +56 -0
  207. package/src/ui/rippleui.css +1 -0
  208. package/src/ui/settings-renderer-advanced.js +195 -0
  209. package/src/ui/settings-renderer-advanced2.js +158 -0
  210. package/src/ui/settings-renderer-teams.js +112 -0
  211. package/src/ui/settings-renderer.js +166 -0
  212. package/src/ui/spacing-system.js +155 -0
  213. package/src/ui/standalone-login.js +109 -0
  214. package/src/ui/styles.css +2530 -0
  215. package/src/ui/styles2.css +1602 -0
  216. package/src/ui/test-page.js +23 -0
  217. package/src/ui/validation-rules.js +73 -0
  218. package/src/ui/validation-ui.js +147 -0
  219. package/src/ui/virtual-scroll.js +107 -0
  220. package/src/ui/webjsx.js +61 -0
  221. package/src/ui/widgets.js +152 -0
@@ -0,0 +1,47 @@
1
+ import { h } from '@/ui/webjsx.js'
2
+ import { TOAST_SCRIPT } from '@/ui/render-helpers.js'
3
+
4
+ export function dataGridAdvanced(config) {
5
+ const { columns = [], data = [], groupBy = null, sortable = true, filterable = true, expandable = false, detailRenderer = null } = config
6
+ const gridId = 'dg-' + Math.random().toString(36).slice(2, 8)
7
+ const colHeaders = columns.map((col, i) => {
8
+ const sortAttr = sortable ? ` data-dg-sort="${col.field}" data-action="dgSort" data-args='["${gridId}","${col.field}","${i},event"]'` : ''
9
+ const filterHtml = filterable ? `<div class="dg-filter"><input type="text" data-dg-filter="${col.field}" placeholder="Filter..." class="dg-filter-input" aria-label="Filter ${col.label || col.field}" oninput="dgFilter('${gridId}')"/></div>` : ''
10
+ return `<th${sortAttr} class="${sortable ? 'dg-sortable' : ''}">${col.label || col.field}<span class="dg-sort-indicator" data-dg-indicator="${col.field}"></span>${filterHtml}</th>`
11
+ }).join('')
12
+ const expandCol = expandable ? '<th class="dg-expand-col"></th>' : ''
13
+ const renderRow = (item, rowId, groupId) => {
14
+ const memberAttr = groupId ? ` data-dg-member="${groupId}"` : ''
15
+ const expandBtn = expandable ? `<td class="dg-expand-col"><button class="dg-expand-btn" data-dg-row="${rowId}" data-action="dgToggleDetail" data-args='["${gridId}"]' data-self aria-label="Expand row details" aria-expanded="false">&#9654;</button></td>` : ''
16
+ const cells = columns.map(col => `<td>${item[col.field] ?? '-'}</td>`).join('')
17
+ const detailRow = expandable ? `<tr class="dg-detail-row"${memberAttr} data-dg-detail="${rowId}" style="display:none"><td colspan="${columns.length + 1}"><div class="dg-detail-panel">${detailRenderer ? detailRenderer(item) : ''}</div></td></tr>` : ''
18
+ return `<tr class="dg-data-row"${memberAttr} data-dg-values='${JSON.stringify(columns.map(c => String(item[c.field] ?? '')))}'>${expandBtn}${cells}</tr>${detailRow}`
19
+ }
20
+ let bodyHtml = ''
21
+ if (groupBy) {
22
+ const groups = {}
23
+ data.forEach(item => { const key = String(item[groupBy] ?? 'Ungrouped'); if (!groups[key]) groups[key] = []; groups[key].push(item) })
24
+ bodyHtml = Object.entries(groups).map(([key, items]) => {
25
+ const gid = `${gridId}-g-${key.replace(/\W/g, '_')}`
26
+ const rows = items.map((item, i) => renderRow(item, `${gid}-r${i}`, gid)).join('')
27
+ return `<tr class="dg-group-header" data-dg-group="${gid}" tabindex="0" data-action="dgToggleGroup" data-args='["${gid}"]' onkeydown="if(event.key==='Enter')dgToggleGroup('${gid}')"><td colspan="${columns.length + (expandable ? 1 : 0)}"><span class="dg-group-arrow">&#9660;</span> <strong>${key}</strong> <span class="dg-group-count">(${items.length})</span></td></tr>${rows}`
28
+ }).join('')
29
+ } else {
30
+ bodyHtml = data.map((item, i) => renderRow(item, `${gridId}-r${i}`, null)).join('')
31
+ }
32
+ const script = `(function(){var sorts={};window.dgSort=function(gid,field,ci,evt){if(!sorts[gid])sorts[gid]=[];if(!evt.shiftKey)sorts[gid]=[];var ex=sorts[gid].find(function(s){return s.field===field});if(ex){ex.dir=ex.dir==='asc'?'desc':'asc'}else{sorts[gid].push({field:field,col:ci,dir:'asc'})}var t=document.getElementById(gid);t.querySelectorAll('[data-dg-indicator]').forEach(function(el){el.textContent=''});sorts[gid].forEach(function(s){var ind=t.querySelector('[data-dg-indicator="'+s.field+'"]');if(ind)ind.textContent=s.dir==='asc'?' \\u25B2':' \\u25BC'});var rows=Array.from(t.querySelectorAll('.dg-data-row'));var tb=t.querySelector('tbody');rows.sort(function(a,b){var va=JSON.parse(a.dataset.dgValues);var vb=JSON.parse(b.dataset.dgValues);for(var i=0;i<sorts[gid].length;i++){var s=sorts[gid][i];var cmp=va[s.col].localeCompare(vb[s.col],undefined,{numeric:true});if(cmp!==0)return s.dir==='asc'?cmp:-cmp}return 0});rows.forEach(function(r){var d=t.querySelector('[data-dg-detail="'+r.querySelector('.dg-expand-btn')?.dataset?.dgRow+'"]');tb.appendChild(r);if(d)tb.appendChild(d)})};window.dgFilter=function(gid){var t=document.getElementById(gid);var fs=Array.from(t.querySelectorAll('[data-dg-filter]'));var vals=fs.map(function(f){return f.value.toLowerCase()});t.querySelectorAll('.dg-data-row').forEach(function(r){var d=JSON.parse(r.dataset.dgValues||'[]');var show=vals.every(function(v,i){return !v||d[i].toLowerCase().indexOf(v)!==-1});r.style.display=show?'':'none';var det=t.querySelector('[data-dg-detail="'+(r.querySelector('.dg-expand-btn')||{}).dataset?.dgRow+'"]');if(det&&!show)det.style.display='none'})};window.dgToggleGroup=function(gid){var ms=document.querySelectorAll('[data-dg-member="'+gid+'"]');var h=document.querySelector('[data-dg-group="'+gid+'"]');var a=h.querySelector('.dg-group-arrow');var hidden=ms[0]&&ms[0].style.display==='none';ms.forEach(function(m){m.style.display=hidden?'':'none'});a.textContent=hidden?'\\u25BC':'\\u25B6'};window.dgToggleDetail=function(gid,btn){var rid=btn.dataset.dgRow;var det=document.querySelector('[data-dg-detail="'+rid+'"]');if(!det)return;var shown=det.style.display!=='none';det.style.display=shown?'none':'';btn.textContent=shown?'\\u25B6':'\\u25BC';btn.classList.toggle('dg-expand-open',!shown)}})();`
33
+ return `<div class="dg-wrapper"><table id="${gridId}" class="dg-table"><thead><tr>${expandCol}${colHeaders}</tr></thead><tbody>${bodyHtml}</tbody></table></div><script>${script}</script>`
34
+ }
35
+
36
+ export function collapsibleSidebar(sections, currentPath) {
37
+ const sidebarId = 'sidebar-' + Math.random().toString(36).slice(2, 8)
38
+ const sectionHtml = sections.map(section => {
39
+ const links = (section.items || []).map(item => {
40
+ const active = currentPath === item.href
41
+ return `<a href="${item.href}" class="sidebar-link${active ? ' sidebar-link-active' : ''}">${item.label}</a>`
42
+ }).join('')
43
+ return `<div class="sidebar-section"><div class="sidebar-section-header" data-action="sidebarToggleSection" data-self>${section.title}<span class="sidebar-section-arrow">&#9660;</span></div><div class="sidebar-section-body">${links}</div></div>`
44
+ }).join('')
45
+ const script = `(function(){var sb=document.getElementById('${sidebarId}');var saved=localStorage.getItem('sidebar-width');if(saved)sb.style.width=saved+'px';var collapsed=localStorage.getItem('sidebar-collapsed')==='true';if(collapsed)sb.classList.add('sidebar-collapsed');window.sidebarToggleSection=function(el){el.parentElement.classList.toggle('sidebar-section-closed')};window.sidebarToggleCollapse=function(){sb.classList.toggle('sidebar-collapsed');localStorage.setItem('sidebar-collapsed',sb.classList.contains('sidebar-collapsed'))};var handle=sb.querySelector('.sidebar-resize-handle');var startX,startW;handle.addEventListener('mousedown',function(e){startX=e.clientX;startW=sb.offsetWidth;function onMove(ev){var w=startW+(ev.clientX-startX);if(w>48&&w<400){sb.style.width=w+'px';localStorage.setItem('sidebar-width',w)}}function onUp(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp)}document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp)})})();`
46
+ return `<aside id="${sidebarId}" class="sidebar-collapsible"><div class="sidebar-inner"><button class="sidebar-collapse-btn" data-action="sidebarToggleCollapse" title="Toggle sidebar">&#9776;</button>${sectionHtml}</div><div class="sidebar-resize-handle"></div></aside><script>${script}</script>`
47
+ }
@@ -0,0 +1,114 @@
1
+ import { generateHtml } from '@/ui/layout.js'
2
+ import { nav as navFn } from '@/ui/layout.js'
3
+ import { AUTH_CSS, GOOGLE_SVG, WARN_SVG, LOGO_SVG, authLogoHtml } from '@/ui/auth-styles.js'
4
+
5
+ export function renderLogin(error = null, hasGoogleAuth = false) {
6
+ const errHtml = error
7
+ ? `<div class="auth-err" role="alert">${WARN_SVG}${String(error).replace(/</g, '&lt;')}</div>`
8
+ : ''
9
+ const googleBtn = hasGoogleAuth
10
+ ? `<a href="/api/auth/google" class="auth-google-btn" aria-label="Continue with Google">${GOOGLE_SVG}Continue with Google</a>
11
+ <div class="auth-divider"><div class="auth-divider-line"></div><span class="auth-divider-text">or</span><div class="auth-divider-line"></div></div>`
12
+ : ''
13
+ const body = `${AUTH_CSS}
14
+ <div class="auth-shell"><div class="auth-wrap">
15
+ ${authLogoHtml('Sign in to your account')}
16
+ <div class="auth-card">
17
+ <div id="login-err-area" role="alert" aria-live="assertive">${errHtml}</div>
18
+ ${googleBtn}
19
+ <form id="login-form" aria-label="Sign in" novalidate>
20
+ <div class="auth-field"><label class="auth-label" for="email">Email address</label>
21
+ <input type="email" name="email" id="email" class="auth-input" placeholder="you@example.com" required autocomplete="email" autofocus></div>
22
+ <div class="auth-field"><label class="auth-label" for="password">Password</label>
23
+ <input type="password" name="password" id="password" class="auth-input" placeholder="Your password" required autocomplete="current-password"></div>
24
+ <div class="auth-forgot"><a href="/password-reset">Forgot password?</a></div>
25
+ <button type="submit" id="login-btn" class="auth-submit">Sign In</button>
26
+ </form>
27
+ </div>
28
+ </div></div>`
29
+ const script = `
30
+ var form=document.getElementById('login-form');
31
+ form.addEventListener('submit',async function(e){
32
+ e.preventDefault();var btn=document.getElementById('login-btn'),errArea=document.getElementById('login-err-area');
33
+ btn.textContent='Signing in\u2026';btn.disabled=true;errArea.innerHTML='';
34
+ try{var res=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:form.email.value,password:form.password.value})});
35
+ var data=await res.json().catch(function(){return{};});
36
+ if(res.ok){btn.textContent='Redirecting\u2026';window.location.href=data.redirect||'/';}
37
+ else{var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=data.error||'Login failed. Please check your credentials.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Sign In';btn.disabled=false;}
38
+ }catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Sign In';btn.disabled=false;}
39
+ });`
40
+ return generateHtml('Sign In | Moonlanding', body, [script])
41
+ }
42
+
43
+ export function renderPasswordReset() {
44
+ const body = `${AUTH_CSS}
45
+ <div class="auth-shell"><div class="auth-wrap">
46
+ ${authLogoHtml('Reset your password')}
47
+ <div class="auth-card">
48
+ <div id="reset-success" class="auth-success" style="display:none" role="alert">If an account exists with that email, a reset link has been sent. Check your inbox.</div>
49
+ <form id="reset-form" aria-label="Password reset request" novalidate>
50
+ <div class="auth-field"><label class="auth-label" for="email">Email address</label>
51
+ <input type="email" name="email" id="email" class="auth-input" placeholder="you@example.com" required autocomplete="email" autofocus></div>
52
+ <div id="reset-err-area" role="alert" aria-live="assertive"></div>
53
+ <button type="submit" id="reset-btn" class="auth-submit">Send Reset Link</button>
54
+ </form>
55
+ <div class="auth-back"><a href="/login">Back to sign in</a></div>
56
+ </div>
57
+ </div></div>`
58
+ const script = `
59
+ var form=document.getElementById('reset-form');
60
+ form.addEventListener('submit',async function(e){
61
+ e.preventDefault();var btn=document.getElementById('reset-btn'),errArea=document.getElementById('reset-err-area');
62
+ btn.textContent='Sending\u2026';btn.disabled=true;errArea.innerHTML='';
63
+ try{var res=await fetch('/api/auth/password-reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:form.email.value})});
64
+ if(res.ok){form.style.display='none';document.getElementById('reset-success').style.display='block';}
65
+ else{var d=await res.json().catch(function(){return{};});var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=d.error||'Request failed. Please try again.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Send Reset Link';btn.disabled=false;}
66
+ }catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Send Reset Link';btn.disabled=false;}
67
+ });`
68
+ return generateHtml('Reset Password | Moonlanding', body, [script])
69
+ }
70
+
71
+ export function renderPasswordResetConfirm(token) {
72
+ const body = `${AUTH_CSS}
73
+ <div class="auth-shell"><div class="auth-wrap">
74
+ ${authLogoHtml('Set a new password')}
75
+ <div class="auth-card">
76
+ <div id="confirm-success" class="auth-success" style="display:none" role="alert">Password updated. <a href="/login" style="color:#15803d;text-decoration:underline;font-weight:600">Sign in now</a></div>
77
+ <form id="confirm-form" aria-label="Set new password" novalidate>
78
+ <input type="hidden" name="token" value="${token}">
79
+ <div class="auth-field"><label class="auth-label" for="password">New password</label>
80
+ <input type="password" name="password" id="password" class="auth-input" placeholder="At least 8 characters" minlength="8" required autocomplete="new-password" autofocus></div>
81
+ <div class="auth-field"><label class="auth-label" for="confirm_password">Confirm new password</label>
82
+ <input type="password" name="confirm_password" id="confirm_password" class="auth-input" placeholder="Same password again" minlength="8" required autocomplete="new-password"></div>
83
+ <div id="confirm-err-area" role="alert" aria-live="assertive"></div>
84
+ <button type="submit" id="confirm-btn" class="auth-submit">Update Password</button>
85
+ </form>
86
+ <div class="auth-back"><a href="/login">Back to sign in</a></div>
87
+ </div>
88
+ </div></div>`
89
+ const script = `
90
+ var form=document.getElementById('confirm-form');
91
+ form.addEventListener('submit',async function(e){
92
+ e.preventDefault();var pw=form.password.value,cpw=form.confirm_password.value,errArea=document.getElementById('confirm-err-area');
93
+ errArea.innerHTML='';
94
+ if(pw!==cpw){errArea.innerHTML='<div class="auth-err" role="alert">Passwords do not match.</div>';return;}
95
+ var btn=document.getElementById('confirm-btn');btn.textContent='Updating\u2026';btn.disabled=true;
96
+ try{var res=await fetch('/api/auth/password-reset',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:form.token.value,password:pw})});
97
+ var data=await res.json().catch(function(){return{};});
98
+ if(res.ok){form.style.display='none';document.getElementById('confirm-success').style.display='block';}
99
+ else{var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=data.error||'Reset failed. Please try again.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Update Password';btn.disabled=false;}
100
+ }catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Update Password';btn.disabled=false;}
101
+ });`
102
+ return generateHtml('Set New Password | Moonlanding', body, [script])
103
+ }
104
+
105
+ export function renderAccessDenied(user, entityName, action) {
106
+ const txt = { list: 'view this list', view: 'view this item', create: 'create items here', edit: 'edit this item', delete: 'delete this item' }
107
+ const body = `<div class="min-h-screen">${navFn(user)}<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;text-align:center;padding:40px 20px">
108
+ <div style="font-size:48px;margin-bottom:16px" aria-hidden="true">&#128274;</div>
109
+ <h1 style="font-size:24px;font-weight:700;color:var(--color-text,#0f172a);margin:0 0 8px">Access Denied</h1>
110
+ <p style="color:var(--color-text-muted,#64748b);margin:0 0 24px;max-width:400px">You do not have permission to ${txt[action] || action} in ${entityName}.</p>
111
+ <a href="/" class="btn-primary-clean">Return to Dashboard</a>
112
+ </div></div>`
113
+ return generateHtml('Access Denied | Moonlanding', body)
114
+ }
@@ -0,0 +1,53 @@
1
+ export const AUTH_CSS = `<style>
2
+ .auth-shell{min-height:100vh;background:var(--color-bg,#f1f5f9);display:flex;align-items:center;justify-content:center;padding:16px}
3
+ .auth-wrap{width:100%;max-width:400px}
4
+ .auth-logo{text-align:center;margin-bottom:32px}
5
+ .auth-logo-mark{display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;background:#04141f;border-radius:14px;margin-bottom:14px;box-shadow:0 4px 12px rgba(4,20,31,0.25)}
6
+ .auth-logo-name{font-size:1.35rem;font-weight:800;color:var(--color-text,#0f172a);letter-spacing:0.5px;display:block;margin-bottom:4px}
7
+ .auth-logo-sub{color:var(--color-text-muted,#64748b);font-size:0.875rem}
8
+ .auth-card{background:var(--color-surface,#fff);border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,0.09);padding:32px;border:1px solid var(--color-border,#e2e8f0)}
9
+ .auth-google-btn{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;padding:11px;border:1px solid var(--color-border,#e2e8f0);border-radius:8px;background:var(--color-surface,#fff);color:var(--color-text,#0f172a);font-size:0.875rem;font-weight:500;text-decoration:none;margin-bottom:20px;transition:all 0.15s;cursor:pointer;min-height:44px}
10
+ .auth-google-btn:hover{background:var(--color-bg,#f1f5f9);border-color:#94a3b8}
11
+ .auth-google-btn:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
12
+ .auth-divider{display:flex;align-items:center;gap:12px;margin-bottom:20px}
13
+ .auth-divider-line{flex:1;height:1px;background:var(--color-border,#e2e8f0)}
14
+ .auth-divider-text{font-size:0.75rem;color:var(--color-text-muted,#64748b);font-weight:500;text-transform:uppercase;letter-spacing:0.5px}
15
+ .auth-field{margin-bottom:18px}
16
+ .auth-label{display:block;font-size:0.8125rem;font-weight:600;color:var(--color-text,#0f172a);margin-bottom:6px}
17
+ .auth-input{width:100%;padding:10px 13px;border:1.5px solid var(--color-border,#e2e8f0);border-radius:8px;font-size:0.9rem;outline:none;transition:border-color 0.15s,box-shadow 0.15s;background:var(--color-surface,#fff);color:var(--color-text,#0f172a);min-height:44px;box-sizing:border-box}
18
+ .auth-input:focus{border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,0.12)}
19
+ .auth-forgot{text-align:right;margin-bottom:22px}
20
+ .auth-forgot a{font-size:0.8125rem;color:#2563eb;text-decoration:none;font-weight:500}
21
+ .auth-forgot a:hover{text-decoration:underline}
22
+ .auth-submit{width:100%;padding:12px;background:#04141f;color:#fff;border:none;border-radius:8px;font-size:0.9375rem;font-weight:600;cursor:pointer;transition:all 0.15s;min-height:46px}
23
+ .auth-submit:hover:not(:disabled){background:#0a2535;box-shadow:0 4px 12px rgba(4,20,31,0.2)}
24
+ .auth-submit:disabled{opacity:0.65;cursor:not-allowed}
25
+ .auth-submit:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
26
+ .auth-err{background:#fee2e2;border:1px solid #fca5a5;color:#b91c1c;padding:11px 14px;border-radius:8px;margin-bottom:18px;font-size:0.875rem;display:flex;align-items:flex-start;gap:8px;line-height:1.4}
27
+ .auth-success{background:#dcfce7;border:1px solid #86efac;color:#15803d;padding:12px 14px;border-radius:8px;margin-bottom:18px;font-size:0.875rem}
28
+ .auth-back{text-align:center;margin-top:16px;font-size:0.875rem;color:var(--color-text-muted,#64748b)}
29
+ .auth-back a{color:#2563eb;text-decoration:none;font-weight:500}
30
+ .auth-back a:hover{text-decoration:underline}
31
+ @media(prefers-color-scheme:dark){
32
+ .auth-logo-mark{background:#1e3a5f}
33
+ .auth-card{background:#1a1a1a;border-color:#334155;box-shadow:0 4px 24px rgba(0,0,0,0.4)}
34
+ .auth-google-btn{background:#1a1a1a;border-color:#334155;color:#f1f5f9}
35
+ .auth-google-btn:hover{background:#2d2d2d}
36
+ .auth-input{background:#1a1a1a;border-color:#334155;color:#f1f5f9}
37
+ .auth-submit{background:#1e3a5f}
38
+ .auth-submit:hover:not(:disabled){background:#1e293b}
39
+ }
40
+ </style>`;
41
+
42
+ export const GOOGLE_SVG = `<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.706c-.18-.54-.282-1.117-.282-1.706s.102-1.166.282-1.706V4.962H.957C.347 6.175 0 7.55 0 9s.348 2.825.957 4.038l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58z"/></svg>`;
43
+
44
+ export const WARN_SVG = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;margin-top:1px" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`;
45
+
46
+ export const LOGO_SVG = `<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6M4.22 4.22l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M4.22 19.78l4.24-4.24m4.24-4.24l4.24-4.24"/></svg>`;
47
+
48
+ export const authLogoHtml = (subtitle) => `
49
+ <div class="auth-logo">
50
+ <div class="auth-logo-mark">${LOGO_SVG}</div>
51
+ <span class="auth-logo-name">MOONLANDING</span>
52
+ <div class="auth-logo-sub">${subtitle}</div>
53
+ </div>`;
@@ -0,0 +1,99 @@
1
+ import { createElement, applyDiff } from 'webjsx';
2
+
3
+ export { createElement, applyDiff };
4
+
5
+ export function showToast(message, type = 'info') {
6
+ let container = document.getElementById('toast-container');
7
+ if (!container) {
8
+ container = document.createElement('div');
9
+ container.id = 'toast-container';
10
+ container.className = 'toast-container';
11
+ document.body.appendChild(container);
12
+ }
13
+ const toast = document.createElement('div');
14
+ toast.className = 'toast toast-' + type;
15
+ toast.textContent = message;
16
+ toast.style.cssText = 'position:relative'; // Ensure proper stacking on mobile
17
+ container.appendChild(toast);
18
+ setTimeout(() => {
19
+ toast.style.opacity = '0';
20
+ setTimeout(() => toast.remove(), 300);
21
+ }, 3000);
22
+ }
23
+
24
+ export function showDialog(id) {
25
+ const el = document.getElementById(id);
26
+ if (el) {
27
+ el.style.display = 'flex';
28
+ const focusable = el.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
29
+ if (focusable) setTimeout(() => focusable.focus(), 100);
30
+ }
31
+ }
32
+
33
+ export function hideDialog(id) {
34
+ const el = document.getElementById(id);
35
+ if (el) el.style.display = 'none';
36
+ }
37
+
38
+ export async function submitForm(formId, url, method = 'POST') {
39
+ const form = document.getElementById(formId);
40
+ if (!form) return;
41
+ const fd = new FormData(form);
42
+ const data = {};
43
+ for (const [k, v] of fd.entries()) data[k] = v;
44
+ form.querySelectorAll('input[type=checkbox]').forEach(cb => {
45
+ data[cb.name] = cb.checked;
46
+ });
47
+ form.querySelectorAll('input[type=number]').forEach(inp => {
48
+ if (inp.name && data[inp.name] !== undefined && data[inp.name] !== '')
49
+ data[inp.name] = Number(data[inp.name]);
50
+ });
51
+ try {
52
+ const res = await fetch(url, {
53
+ method,
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify(data)
56
+ });
57
+ const result = await res.json().catch(() => ({}));
58
+ if (res.ok) {
59
+ showToast(method === 'POST' ? 'Created' : 'Updated', 'success');
60
+ return { ok: true, data: result.data || result };
61
+ }
62
+ showToast(result.message || result.error || 'Save failed', 'error');
63
+ return { ok: false, data: result };
64
+ } catch (err) {
65
+ showToast('Error: ' + err.message, 'error');
66
+ return { ok: false, error: err };
67
+ }
68
+ }
69
+
70
+ export async function confirmDelete(url, redirectUrl) {
71
+ if (!confirm('Are you sure you want to delete this?')) return false;
72
+ try {
73
+ const res = await fetch(url, { method: 'DELETE' });
74
+ if (res.ok) {
75
+ showToast('Deleted', 'success');
76
+ if (redirectUrl) setTimeout(() => { window.location = redirectUrl; }, 500);
77
+ return true;
78
+ }
79
+ const d = await res.json().catch(() => ({}));
80
+ showToast(d.message || d.error || 'Delete failed', 'error');
81
+ return false;
82
+ } catch (err) {
83
+ showToast('Error: ' + err.message, 'error');
84
+ return false;
85
+ }
86
+ }
87
+
88
+ export function toggleElement(id) {
89
+ const el = document.getElementById(id);
90
+ if (!el) return;
91
+ el.style.display = el.style.display === 'none' ? '' : 'none';
92
+ }
93
+
94
+ window.showToast = showToast;
95
+ window.showDialog = showDialog;
96
+ window.hideDialog = hideDialog;
97
+ window.submitForm = submitForm;
98
+ window.confirmDelete = confirmDelete;
99
+ window.toggleElement = toggleElement;
@@ -0,0 +1,31 @@
1
+ export function collaboratorManagementDialog(entityType, entityId) {
2
+ return `<div id="collab-mgmt-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="collab-mgmt-dialog-title" aria-hidden="true">
3
+ <div class="dialog-panel" style="max-width:640px">
4
+ <div class="dialog-header"><span class="dialog-title" id="collab-mgmt-dialog-title">Manage Collaborators</span><button class="dialog-close" data-dialog-close="collab-mgmt-dialog" aria-label="Close dialog">&times;</button></div>
5
+ <div class="dialog-body">
6
+ <div id="cmd-list" class="flex flex-col gap-2" style="max-height:300px;overflow:auto"><div class="text-gray-500 text-sm text-center py-4">Loading...</div></div>
7
+ <div class="inline-form" style="margin-top:1rem">
8
+ <div class="inline-form-row">
9
+ <div class="inline-form-field" style="flex:1"><label for="cmd-email">Email</label><input type="email" id="cmd-email" class="input input-bordered input-sm w-full" placeholder="collaborator@example.com"/></div>
10
+ <div class="inline-form-field"><label for="cmd-role">Role</label><select id="cmd-role" class="select select-bordered select-sm"><option value="viewer">Viewer</option><option value="commenter">Commenter</option><option value="reviewer" selected>Reviewer</option><option value="manager">Manager</option></select></div>
11
+ <div class="inline-form-field"><label>&nbsp;</label><button class="btn btn-primary btn-sm" data-action="cmdAdd">Add</button></div>
12
+ </div>
13
+ </div>
14
+ </div>
15
+ <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="collab-mgmt-dialog">Close</button></div>
16
+ </div></div>
17
+ <script>
18
+ window.openCollabMgmt=function(){document.getElementById('collab-mgmt-dialog').style.display='flex';cmdLoad()};
19
+ async function cmdLoad(){var c=document.getElementById('cmd-list');c.innerHTML='<div class="text-gray-500 text-sm text-center py-2">Loading...</div>';try{var res=await fetch('/api/collaborator?${entityType}_id=${entityId}');var d=await res.json();var items=d.data||d||[];c.innerHTML='';if(!items.length){c.innerHTML='<div class="text-gray-500 text-sm text-center py-2">No collaborators</div>';return}items.forEach(function(item){var div=document.createElement('div');div.className='flex items-center justify-between gap-2';div.style.padding='0.5rem 0';div.style.borderBottom='1px solid #f3f4f6';var expired=item.expires_at&&item.expires_at<=Math.floor(Date.now()/1000);var expiryText=item.expires_at?'Expires: '+new Date(item.expires_at*1000).toLocaleDateString():'Permanent';if(expired)expiryText='<span style="color:#ef4444">Expired</span>';div.innerHTML='<div><div class="text-sm font-medium">'+(item.email||item.name||'Unknown')+'</div><div class="text-xs text-gray-500">'+(item.role_type||item.access_type||'viewer')+' &middot; '+expiryText+'</div></div><div class="flex gap-1"><button class="btn btn-xs btn-ghost" data-action="cmdExtend" data-args='["'+item.id+'"]'>Extend</button><button class="btn btn-xs btn-error btn-outline" data-action="cmdRevoke" data-args='["'+item.id+'"]'>Revoke</button></div>';c.appendChild(div)})}catch(e){c.innerHTML='<div class="text-red-500 text-sm text-center">Error loading</div>'}}
20
+ window.cmdAdd=async function(){var email=document.getElementById('cmd-email').value.trim();var role=document.getElementById('cmd-role').value;if(!email){showToast('Email required','error');return}try{var r=await fetch('/api/collaborator',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,role_type:role,${entityType}_id:'${entityId}'})});if(r.ok){showToast('Collaborator added','success');document.getElementById('cmd-email').value='';cmdLoad()}else{var d=await r.json().catch(function(){return{}});showToast(d.error||'Failed','error')}}catch(e){showToast('Error','error')}};
21
+ window.cmdRevoke=async function(id){if(!confirm('Revoke access?'))return;try{var r=await fetch('/api/collaborator/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');cmdLoad()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
22
+ window.cmdExtend=async function(id){var days=prompt('Extend by how many days?','7');if(!days)return;var ts=Math.floor(Date.now()/1000)+Number(days)*86400;try{var r=await fetch('/api/collaborator/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({expires_at:ts})});if(r.ok){showToast('Extended','success');cmdLoad()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
23
+ </script>`;
24
+ }
25
+
26
+ export function collaboratorExpiryUI(entityType, entityId) {
27
+ return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body"><div class="flex justify-between items-center"><h3 style="font-size:0.875rem;font-weight:600">Collaborator Access</h3><button class="btn btn-outline btn-sm" data-action="openCollabMgmt">Manage</button></div><div id="collab-expiry-list" class="mt-3 flex flex-col gap-1"></div></div></div>
28
+ <script>
29
+ (function(){fetch('/api/collaborator?${entityType}_id=${entityId}').then(function(r){return r.json()}).then(function(d){var items=d.data||d||[];var el=document.getElementById('collab-expiry-list');var now=Math.floor(Date.now()/1000);var expiring=items.filter(function(c){return c.expires_at&&c.expires_at>now&&c.expires_at-now<3*86400});var expired=items.filter(function(c){return c.expires_at&&c.expires_at<=now});if(!expiring.length&&!expired.length){el.innerHTML='<div class="text-xs text-gray-500">All access current</div>';return}var html='';expired.forEach(function(c){html+='<div class="text-xs" style="color:#ef4444">'+c.email+' - Expired</div>'});expiring.forEach(function(c){var days=Math.ceil((c.expires_at-now)/86400);html+='<div class="text-xs" style="color:#f59e0b">'+c.email+' - Expires in '+days+'d</div>'});el.innerHTML=html}).catch(function(){})})();
30
+ </script>`;
31
+ }
@@ -0,0 +1,156 @@
1
+ export const commonHandlers = {
2
+ dialogs: {
3
+ close(dialogId) {
4
+ const el = document.getElementById(dialogId);
5
+ if (el) el.style.display = 'none';
6
+ },
7
+ open(dialogId, display = 'flex') {
8
+ const el = document.getElementById(dialogId);
9
+ if (el) el.style.display = display;
10
+ },
11
+ toggle(dialogId) {
12
+ const el = document.getElementById(dialogId);
13
+ if (el) el.style.display = el.style.display === 'none' ? 'flex' : 'none';
14
+ }
15
+ },
16
+
17
+ nav: {
18
+ go(path) { window.location = path; },
19
+ external(url) { window.open(url, '_blank'); },
20
+ back() { window.history.back(); }
21
+ },
22
+
23
+ forms: {
24
+ reset(formId) {
25
+ const form = document.getElementById(formId);
26
+ if (form?.reset) form.reset();
27
+ },
28
+ submit(formId) {
29
+ const form = document.getElementById(formId);
30
+ if (form) form.dispatchEvent(new Event('submit', { bubbles: true }));
31
+ },
32
+ disable(formId) {
33
+ const form = document.getElementById(formId);
34
+ if (form) {
35
+ form.querySelectorAll('input, button, select, textarea').forEach(el => el.disabled = true);
36
+ }
37
+ },
38
+ enable(formId) {
39
+ const form = document.getElementById(formId);
40
+ if (form) {
41
+ form.querySelectorAll('input, button, select, textarea').forEach(el => el.disabled = false);
42
+ }
43
+ }
44
+ },
45
+
46
+ dom: {
47
+ show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; },
48
+ hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; },
49
+ toggle(id) {
50
+ const el = document.getElementById(id);
51
+ if (el) el.style.display = el.style.display === 'none' ? '' : 'none';
52
+ },
53
+ addClass(id, cls) {
54
+ const el = document.getElementById(id);
55
+ if (el) el.classList.add(...cls.split(' '));
56
+ },
57
+ removeClass(id, cls) {
58
+ const el = document.getElementById(id);
59
+ if (el) el.classList.remove(...cls.split(' '));
60
+ },
61
+ toggleClass(id, cls) {
62
+ const el = document.getElementById(id);
63
+ if (el) el.classList.toggle(cls);
64
+ },
65
+ setText(id, text) {
66
+ const el = document.getElementById(id);
67
+ if (el) el.textContent = text;
68
+ },
69
+ setHtml(id, html) {
70
+ const el = document.getElementById(id);
71
+ if (el) el.innerHTML = html;
72
+ },
73
+ getValue(id) {
74
+ const el = document.getElementById(id);
75
+ return el?.value || '';
76
+ },
77
+ setValue(id, value) {
78
+ const el = document.getElementById(id);
79
+ if (el) el.value = value;
80
+ },
81
+ focus(id) {
82
+ const el = document.getElementById(id);
83
+ if (el) el.focus();
84
+ },
85
+ remove(id) {
86
+ const el = document.getElementById(id);
87
+ if (el) el.remove();
88
+ }
89
+ },
90
+
91
+ selection: {
92
+ toggleCheckbox(id) {
93
+ const el = document.getElementById(id);
94
+ if (el?.type === 'checkbox') el.checked = !el.checked;
95
+ },
96
+ setChecked(id, checked) {
97
+ const el = document.getElementById(id);
98
+ if (el?.type === 'checkbox') el.checked = checked;
99
+ },
100
+ isChecked(id) {
101
+ const el = document.getElementById(id);
102
+ return el?.type === 'checkbox' && el.checked;
103
+ },
104
+ toggleActive(id) {
105
+ const el = document.getElementById(id);
106
+ if (el) el.classList.toggle('active');
107
+ }
108
+ },
109
+
110
+ data: {
111
+ getSelectedRows() {
112
+ return Array.from(document.querySelectorAll('input[data-row-select]:checked'))
113
+ .map(el => el.dataset.rowId);
114
+ },
115
+ clearSelections() {
116
+ document.querySelectorAll('input[data-row-select]').forEach(el => el.checked = false);
117
+ },
118
+ selectAll(selector = 'input[data-row-select]') {
119
+ document.querySelectorAll(selector).forEach(el => el.checked = true);
120
+ }
121
+ },
122
+
123
+ notify: {
124
+ show(message, type = 'info') {
125
+ if (typeof showToast === 'function') {
126
+ showToast(message, type);
127
+ }
128
+ },
129
+ success(message) { this.show(message, 'success'); },
130
+ error(message) { this.show(message, 'error'); },
131
+ warning(message) { this.show(message, 'warning'); },
132
+ info(message) { this.show(message, 'info'); }
133
+ },
134
+
135
+ keyboard: {
136
+ isEnter(event) { return event.key === 'Enter'; },
137
+ isEscape(event) { return event.key === 'Escape'; },
138
+ isSpace(event) { return event.key === ' '; },
139
+ preventDefault(event) { event.preventDefault(); event.stopPropagation(); }
140
+ },
141
+
142
+ utils: {
143
+ debounce(fn, delay = 300) {
144
+ let timer;
145
+ return function(...args) {
146
+ clearTimeout(timer);
147
+ timer = setTimeout(() => fn.apply(this, args), delay);
148
+ };
149
+ },
150
+ confirm(message) { return window.confirm(message); },
151
+ prompt(message, defaultValue = '') { return window.prompt(message, defaultValue); },
152
+ wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
153
+ }
154
+ };
155
+
156
+ export const { dialogs, nav, forms, dom, selection, data, notify, keyboard, utils } = commonHandlers;
@@ -0,0 +1,103 @@
1
+ import { getConfigEngineSync } from '@/lib/config-generator-engine';
2
+
3
+ function getComponentDefs() {
4
+ try {
5
+ const engine = getConfigEngineSync();
6
+ const config = engine.getConfig();
7
+ return config?.components || {};
8
+ } catch {
9
+ return {};
10
+ }
11
+ }
12
+
13
+ let componentDefs = getComponentDefs();
14
+
15
+ export function renderComponent(componentId, props = {}) {
16
+ const def = componentDefs[componentId];
17
+ if (!def) throw new Error(`Component not found: ${componentId}`);
18
+
19
+ const errors = validateComponentProps(componentId, props);
20
+ if (errors.length > 0) throw new Error(`Invalid props for ${componentId}: ${errors.join(', ')}`);
21
+
22
+ let html = def.template;
23
+ Object.entries(props).forEach(([key, value]) => {
24
+ const regex = new RegExp(`{{${key}}}`, 'g');
25
+ if (value && typeof value === 'object') {
26
+ if (Array.isArray(value)) {
27
+ html = html.replace(regex, value.map(v => renderItem(v, key)).join(''));
28
+ } else {
29
+ html = html.replace(regex, JSON.stringify(value));
30
+ }
31
+ } else {
32
+ html = html.replace(regex, String(value ?? ''));
33
+ }
34
+ });
35
+
36
+ html = html.replace(/{{[^}]+}}/g, '');
37
+
38
+ const cssClass = def.class ? ` class="${def.class}"` : '';
39
+ const element = def.element || 'div';
40
+
41
+ return `<${element}${cssClass}>${html}</${element}>`;
42
+ }
43
+
44
+ function renderItem(item, key) {
45
+ if (typeof item === 'string') return item;
46
+ if (typeof item === 'object' && item.name && item.color) {
47
+ return `<span class="avatar-item" style="background:${item.color}">${item.name.charAt(0)}</span>`;
48
+ }
49
+ return String(item);
50
+ }
51
+
52
+ export function getComponentConfig(componentId) {
53
+ const def = componentDefs[componentId];
54
+ if (!def) throw new Error(`Component not found: ${componentId}`);
55
+ return { ...def };
56
+ }
57
+
58
+ export function validateComponentProps(componentId, props = {}) {
59
+ const def = componentDefs[componentId];
60
+ if (!def) throw new Error(`Component not found: ${componentId}`);
61
+
62
+ const errors = [];
63
+ const propDefs = def.properties || [];
64
+
65
+ propDefs.forEach(propDef => {
66
+ if (propDef.required && !(propDef.name in props)) {
67
+ errors.push(`Missing required property: ${propDef.name}`);
68
+ }
69
+
70
+ if (propDef.name in props && propDef.type) {
71
+ const value = props[propDef.name];
72
+ const actualType = Array.isArray(value) ? 'list' : typeof value;
73
+
74
+ if (propDef.type !== 'function' && actualType !== propDef.type) {
75
+ errors.push(`Invalid type for ${propDef.name}: expected ${propDef.type}, got ${actualType}`);
76
+ }
77
+
78
+ if (propDef.type === 'number' && typeof value === 'number') {
79
+ if ('min' in propDef && value < propDef.min) {
80
+ errors.push(`${propDef.name} below minimum: ${propDef.min}`);
81
+ }
82
+ if ('max' in propDef && value > propDef.max) {
83
+ errors.push(`${propDef.name} exceeds maximum: ${propDef.max}`);
84
+ }
85
+ }
86
+
87
+ if (propDef.type === 'enum' && propDef.options) {
88
+ if (!propDef.options.includes(value)) {
89
+ errors.push(`${propDef.name} not in allowed options: ${propDef.options.join(',')}`);
90
+ }
91
+ }
92
+ }
93
+ });
94
+
95
+ return errors;
96
+ }
97
+
98
+ export function getAvailableComponents() {
99
+ return Object.keys(componentDefs).map(id => ({
100
+ id,
101
+ ...componentDefs[id]
102
+ }));
103
+ }