section-builder.php 51 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Section Builder — Tasmanian Planning Scheme Assistant</title>
  7. <meta name="description" content="Build a full supporting planning report section by section using AI-generated drafts from the Tasmanian Planning Scheme.">
  8. <link rel="canonical" href="https://tasplanning.report/section-builder">
  9. <meta name="robots" content="noindex,follow">
  10. <link rel="preconnect" href="https://fonts.googleapis.com">
  11. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  12. <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&display=swap" rel="stylesheet">
  13. <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
  14. <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  15. <script src="https://cdn.jsdelivr.net/npm/html-docx-js/dist/html-docx.js"></script>
  16. <link rel="icon" href="/favicon.ico">
  17. <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
  18. <style>
  19. /* ── Design tokens ───────────────────────────────────────────────── */
  20. :root {
  21. --bg: #0b0f0e;
  22. --bg-1: #111614;
  23. --bg-2: #181e1b;
  24. --bg-card: #141a17;
  25. --border: rgba(255,255,255,0.07);
  26. --border-hover: rgba(255,255,255,0.14);
  27. --accent: #2ddc8a;
  28. --accent-dim: rgba(45,220,138,0.10);
  29. --accent-glow: rgba(45,220,138,0.22);
  30. --text-primary: #eaf0ec;
  31. --text-secondary:#8fa899;
  32. --text-muted: #4f6459;
  33. --danger: #f08080;
  34. --warn: #f0c060;
  35. --info: #60b8f0;
  36. --serif: 'DM Serif Display', Georgia, serif;
  37. --sans: 'DM Sans', system-ui, sans-serif;
  38. --mono: ui-monospace, 'Cascadia Code', Menlo, monospace;
  39. --radius: 10px;
  40. --radius-lg: 16px;
  41. --radius-sm: 5px;
  42. --transition: 0.16s cubic-bezier(0.4,0,0.2,1);
  43. }
  44. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  45. html { height: 100%; }
  46. body {
  47. font-family: var(--sans);
  48. background: var(--bg);
  49. color: var(--text-primary);
  50. font-size: 14px;
  51. line-height: 1.6;
  52. -webkit-font-smoothing: antialiased;
  53. height: 100%;
  54. display: flex;
  55. flex-direction: column;
  56. }
  57. ::selection { background: var(--accent); color: #0b0f0e; }
  58. /* ── Nav ─────────────────────────────────────────────────────────── */
  59. .site-nav {
  60. background: rgba(11,15,14,0.95);
  61. backdrop-filter: blur(12px);
  62. border-bottom: 1px solid var(--border);
  63. flex-shrink: 0;
  64. position: sticky; top: 0; z-index: 100;
  65. }
  66. .nav-inner {
  67. display: flex; align-items: center; justify-content: space-between;
  68. padding: 0 20px; height: 54px; gap: 16px;
  69. }
  70. .nav-brand {
  71. display: flex; align-items: center; gap: 9px;
  72. font-size: 0.85rem; font-weight: 500; color: var(--text-primary);
  73. text-decoration: none; white-space: nowrap;
  74. }
  75. .nav-brand svg { flex-shrink: 0; }
  76. .nav-crumb {
  77. display: flex; align-items: center; gap: 6px;
  78. font-size: 0.8rem; color: var(--text-muted);
  79. }
  80. .nav-crumb a { color: var(--text-secondary); text-decoration: none; }
  81. .nav-crumb a:hover { color: var(--accent); }
  82. .nav-crumb .sep { color: var(--text-muted); }
  83. .status-dot {
  84. width: 7px; height: 7px; border-radius: 50%;
  85. background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
  86. display: inline-block; animation: pulse 2.5s ease-in-out infinite;
  87. }
  88. @keyframes pulse { 0%,100%{opacity:1}50%{opacity:.4} }
  89. .nav-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
  90. .nav-status { display: flex; align-items: center; gap: 5px; font-size: 0.72rem; color: var(--text-muted); }
  91. /* ── Context banner (shown when context arrives) ─────────────────── */
  92. .ctx-banner {
  93. background: var(--accent-dim); border-bottom: 1px solid rgba(45,220,138,0.2);
  94. padding: 9px 20px; display: flex; align-items: center; gap: 10px;
  95. font-size: 0.8rem; color: var(--accent); flex-shrink: 0;
  96. }
  97. .ctx-banner.hidden { display: none; }
  98. .ctx-banner strong { color: var(--text-primary); }
  99. /* ── Layout: 3-panel ─────────────────────────────────────────────── */
  100. .app-body {
  101. display: grid;
  102. grid-template-columns: 280px 1fr 380px;
  103. flex: 1;
  104. min-height: 0;
  105. overflow: hidden;
  106. }
  107. @media(max-width: 1100px) {
  108. .app-body { grid-template-columns: 260px 1fr; }
  109. .panel-right { display: none; }
  110. }
  111. @media(max-width: 720px) {
  112. .app-body { grid-template-columns: 1fr; }
  113. .panel-left { display: none; }
  114. }
  115. /* ── Panels ──────────────────────────────────────────────────────── */
  116. .panel {
  117. overflow-y: auto; display: flex; flex-direction: column;
  118. border-right: 1px solid var(--border);
  119. }
  120. .panel:last-child { border-right: none; }
  121. .panel-header {
  122. padding: 14px 16px 10px; border-bottom: 1px solid var(--border);
  123. flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
  124. position: sticky; top: 0; background: var(--bg-1); z-index: 10;
  125. }
  126. .panel-header h2 {
  127. font-size: 0.72rem; font-weight: 500; letter-spacing: 0.1em;
  128. text-transform: uppercase; color: var(--text-muted);
  129. }
  130. .panel-body { padding: 14px 16px; flex: 1; }
  131. /* ── Form elements ───────────────────────────────────────────────── */
  132. label.field-label {
  133. display: block; font-size: 0.7rem; font-weight: 500;
  134. letter-spacing: 0.08em; text-transform: uppercase;
  135. color: var(--text-muted); margin-bottom: 5px;
  136. }
  137. textarea, input[type=text], input[type=number], select {
  138. width: 100%;
  139. background: var(--bg-2); border: 1px solid var(--border);
  140. border-radius: var(--radius-sm); color: var(--text-primary);
  141. font-family: var(--sans); font-size: 0.82rem; outline: none;
  142. transition: border-color var(--transition);
  143. padding: 8px 10px;
  144. }
  145. textarea { resize: vertical; min-height: 80px; line-height: 1.55; }
  146. textarea.mono { font-family: var(--mono); font-size: 0.75rem; }
  147. textarea:focus, input:focus, select:focus {
  148. border-color: var(--accent);
  149. box-shadow: 0 0 0 2px var(--accent-dim);
  150. }
  151. textarea::placeholder, input::placeholder { color: var(--text-muted); }
  152. select option { background: var(--bg-2); }
  153. .field-group { margin-bottom: 14px; }
  154. .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
  155. /* ── Toggle switch ───────────────────────────────────────────────── */
  156. .toggle-row {
  157. display: flex; align-items: center; gap: 8px;
  158. margin-bottom: 8px; cursor: pointer;
  159. }
  160. .toggle-track {
  161. position: relative; width: 32px; height: 18px;
  162. background: var(--bg-2); border: 1px solid var(--border);
  163. border-radius: 999px; flex-shrink: 0;
  164. transition: background var(--transition), border-color var(--transition);
  165. }
  166. .toggle-track:has(input:checked) { background: var(--accent); border-color: var(--accent); }
  167. .toggle-track input {
  168. position: absolute; opacity: 0; width: 100%; height: 100%;
  169. cursor: pointer; margin: 0; border: none; background: none;
  170. }
  171. .toggle-knob {
  172. position: absolute; top: 2px; left: 2px;
  173. width: 12px; height: 12px; background: #fff;
  174. border-radius: 50%; pointer-events: none;
  175. transition: transform var(--transition);
  176. }
  177. .toggle-track:has(input:checked) .toggle-knob { transform: translateX(14px); }
  178. .toggle-label { font-size: 0.78rem; color: var(--text-secondary); }
  179. /* ── Section checkboxes ──────────────────────────────────────────── */
  180. .sec-item {
  181. display: flex; align-items: center; gap: 7px;
  182. padding: 4px 0; cursor: pointer;
  183. }
  184. .sec-item input[type=checkbox] {
  185. width: 14px; height: 14px; flex-shrink: 0;
  186. accent-color: var(--accent); cursor: pointer;
  187. background: var(--bg-2); border: 1px solid var(--border);
  188. }
  189. .sec-label { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.3; }
  190. .sec-item.depth-0 .sec-label { color: var(--text-primary); font-weight: 500; }
  191. .sec-item.depth-1 { padding-left: 16px; }
  192. .sec-item.depth-2 { padding-left: 32px; }
  193. /* ── Buttons ─────────────────────────────────────────────────────── */
  194. .btn {
  195. display: inline-flex; align-items: center; gap: 6px;
  196. padding: 8px 16px; border-radius: var(--radius);
  197. font-family: var(--sans); font-size: 0.8rem; font-weight: 500;
  198. cursor: pointer; transition: all var(--transition); border: none;
  199. white-space: nowrap; text-decoration: none;
  200. }
  201. .btn-primary { background: var(--accent); color: #0b0f0e; }
  202. .btn-primary:hover:not(:disabled) { background: #3bf59a; transform: translateY(-1px); }
  203. .btn-primary:disabled { opacity: 0.35; cursor: not-allowed; }
  204. .btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-hover); }
  205. .btn-outline:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
  206. .btn-outline:disabled { opacity: 0.3; cursor: not-allowed; }
  207. .btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
  208. .btn-ghost:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-secondary); }
  209. .btn-ghost:disabled { opacity: 0.25; cursor: not-allowed; }
  210. .btn-danger { background: transparent; color: var(--danger); border: 1px solid rgba(240,128,128,0.3); }
  211. .btn-danger:hover { background: rgba(240,128,128,0.08); }
  212. .btn-xs { padding: 4px 10px; font-size: 0.72rem; border-radius: var(--radius-sm); }
  213. .btn-block { width: 100%; justify-content: center; }
  214. .btn-row { display: flex; flex-wrap: wrap; gap: 6px; }
  215. /* ── Status bar ──────────────────────────────────────────────────── */
  216. .status-bar {
  217. padding: 8px 16px; border-top: 1px solid var(--border);
  218. font-size: 0.75rem; color: var(--text-muted); flex-shrink: 0;
  219. display: flex; align-items: center; gap: 8px; min-height: 34px;
  220. }
  221. .status-bar.busy { color: var(--info); }
  222. .status-bar.error { color: var(--danger); }
  223. .spinner {
  224. width: 13px; height: 13px;
  225. border: 2px solid var(--border); border-top-color: var(--accent);
  226. border-radius: 50%; animation: spin .65s linear infinite; flex-shrink: 0;
  227. }
  228. @keyframes spin { to { transform: rotate(360deg); } }
  229. /* ── Section output cards ────────────────────────────────────────── */
  230. .sec-card {
  231. background: var(--bg-card); border: 1px solid var(--border);
  232. border-radius: var(--radius); margin-bottom: 12px;
  233. animation: fadeUp .25s ease;
  234. }
  235. @keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
  236. .sec-card-header {
  237. display: flex; align-items: center; justify-content: space-between;
  238. padding: 10px 14px; border-bottom: 1px solid var(--border);
  239. gap: 10px;
  240. }
  241. .sec-card-title { font-size: 0.82rem; font-weight: 500; color: var(--text-primary); }
  242. .sec-card-meta { font-size: 0.7rem; color: var(--text-muted); margin: 0 6px 0 auto; }
  243. .sec-card-body { padding: 10px 14px; }
  244. .sec-card textarea {
  245. min-height: 140px; background: var(--bg-2);
  246. font-family: var(--mono); font-size: 0.73rem; line-height: 1.5;
  247. }
  248. .sec-sources {
  249. font-size: 0.7rem; color: var(--text-muted);
  250. padding: 6px 14px 10px; border-top: 1px solid var(--border);
  251. }
  252. .sec-sources span { color: var(--text-secondary); }
  253. /* ── Combined / right panel ──────────────────────────────────────── */
  254. .combined-wrap { padding: 14px 16px; }
  255. .combined-wrap textarea {
  256. min-height: 300px; font-family: var(--mono); font-size: 0.73rem;
  257. line-height: 1.5; background: var(--bg-2);
  258. }
  259. .export-row {
  260. display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px;
  261. }
  262. /* ── Divider ─────────────────────────────────────────────────────── */
  263. .divider { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
  264. /* ── Empty state ─────────────────────────────────────────────────── */
  265. .empty-state {
  266. display: flex; flex-direction: column; align-items: center;
  267. justify-content: center; gap: 10px; height: 200px;
  268. color: var(--text-muted); font-size: 0.8rem; text-align: center;
  269. padding: 20px;
  270. }
  271. .empty-state i { font-size: 2rem; opacity: 0.3; }
  272. /* ── Scrollbar ───────────────────────────────────────────────────── */
  273. ::-webkit-scrollbar { width: 5px; height: 5px; }
  274. ::-webkit-scrollbar-track { background: transparent; }
  275. ::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
  276. </style>
  277. <!-- Google tag (gtag.js) -->
  278. <script async src="https://www.googletagmanager.com/gtag/js?id=G-LWEHQVCWEZ"></script>
  279. <script>
  280. window.dataLayer = window.dataLayer || [];
  281. function gtag(){dataLayer.push(arguments);}
  282. gtag('js', new Date());
  283. gtag('config', 'G-LWEHQVCWEZ');
  284. </script>
  285. <!-- Google Tag Manager -->
  286. <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  287. new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  288. j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  289. 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  290. })(window,document,'script','dataLayer','GTM-M5PFLGZT');</script>
  291. <!-- End Google Tag Manager -->
  292. </head>
  293. <body>
  294. <!-- Google Tag Manager (noscript) -->
  295. <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M5PFLGZT"
  296. height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  297. <!-- End Google Tag Manager (noscript) -->
  298. <!-- ── Nav ──────────────────────────────────────────────────────────── -->
  299. <nav class="site-nav">
  300. <div class="nav-inner">
  301. <a class="nav-brand" href="/">
  302. <svg width="22" height="22" viewBox="0 0 28 28" fill="none">
  303. <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
  304. <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  305. <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
  306. </svg>
  307. Tas Planning
  308. </a>
  309. <div class="nav-crumb">
  310. <a href="/site-report.php">Property lookup</a>
  311. <span class="sep">›</span>
  312. <span style="color:var(--text-secondary);">Section builder</span>
  313. </div>
  314. <div class="nav-right">
  315. <div class="nav-status">
  316. <span class="status-dot"></span>
  317. <span class="nav-status-text">API live</span>
  318. </div>
  319. </div>
  320. </div>
  321. </nav>
  322. <!-- ── Context banner ────────────────────────────────────────────────── -->
  323. <div class="ctx-banner hidden" id="ctxBanner">
  324. <i class="bi bi-check-circle-fill"></i>
  325. Context loaded: <strong id="ctxBannerAddr"></strong>
  326. <button class="btn btn-ghost btn-xs" style="margin-left:auto;" onclick="clearContext()">
  327. <i class="bi bi-x"></i> Clear
  328. </button>
  329. </div>
  330. <!-- ── App body ──────────────────────────────────────────────────────── -->
  331. <div class="app-body">
  332. <!-- ── LEFT: Sections + controls ─────────────────────────────────── -->
  333. <div class="panel panel-left" id="panelLeft">
  334. <div class="panel-header">
  335. <h2>Report sections</h2>
  336. <button class="btn btn-ghost btn-xs" id="btnToggleAll">Select all</button>
  337. </div>
  338. <div class="panel-body">
  339. <div id="seclist"></div>
  340. <hr class="divider">
  341. <!-- Generate controls -->
  342. <div class="field-group">
  343. <label class="field-label">Council scope</label>
  344. <select id="council">
  345. <option value="">All councils / SPP only</option>
  346. </select>
  347. </div>
  348. <div class="field-row">
  349. <div>
  350. <label class="field-label">RAG top-k</label>
  351. <input type="number" id="topk" value="6" min="3" max="16">
  352. </div>
  353. <div>
  354. <label class="field-label">Scope</label>
  355. <select id="scopeSelect">
  356. <option value="state_plus_local">State + local</option>
  357. <option value="state_only">State only</option>
  358. <option value="local_only">Local only</option>
  359. <option value="any">Any</option>
  360. </select>
  361. </div>
  362. </div>
  363. <label class="toggle-row">
  364. <span class="toggle-track"><input type="checkbox" id="includeSources" checked><span class="toggle-knob"></span></span>
  365. <span class="toggle-label">Show sources under each section</span>
  366. </label>
  367. <hr class="divider">
  368. <div class="btn-row" style="margin-bottom:8px;">
  369. <button class="btn btn-primary" id="btnGenerate" style="flex:1;">
  370. <i class="bi bi-stars"></i> Generate selected
  371. </button>
  372. </div>
  373. <div class="btn-row">
  374. <button class="btn btn-outline" id="btnAssemble" disabled>
  375. <i class="bi bi-collection"></i> Assemble
  376. </button>
  377. <button class="btn btn-ghost" id="btnCopyAll" disabled>
  378. <i class="bi bi-clipboard"></i> Copy
  379. </button>
  380. <button class="btn btn-ghost" id="btnDownloadMd" disabled>
  381. <i class="bi bi-file-text"></i> .md
  382. </button>
  383. </div>
  384. </div>
  385. <div class="status-bar" id="statusBar">
  386. <i class="bi bi-info-circle"></i> Ready
  387. </div>
  388. </div>
  389. <!-- ── CENTRE: Section drafts ─────────────────────────────────────── -->
  390. <div class="panel panel-centre">
  391. <div class="panel-header">
  392. <h2>Section drafts</h2>
  393. <div style="display:flex;gap:6px;">
  394. <button class="btn btn-ghost btn-xs" id="btnClearDrafts">Clear all</button>
  395. </div>
  396. </div>
  397. <div style="padding:14px 16px;flex:1;" id="outputs">
  398. <div class="empty-state" id="emptyState">
  399. <i class="bi bi-file-earmark-text"></i>
  400. <div>Select sections on the left and click<br><strong style="color:var(--text-secondary)">Generate selected</strong> to start drafting.</div>
  401. </div>
  402. </div>
  403. </div>
  404. <!-- ── RIGHT: Context + Combined ─────────────────────────────────── -->
  405. <div class="panel panel-right" id="panelRight">
  406. <!-- Context -->
  407. <div class="panel-header">
  408. <h2>Project context</h2>
  409. <div style="display:flex;gap:6px;">
  410. <button class="btn btn-ghost btn-xs" id="btnPrettyCtx">Format</button>
  411. <button class="btn btn-ghost btn-xs" id="btnClearCtx">Clear</button>
  412. </div>
  413. </div>
  414. <div class="panel-body">
  415. <div class="field-group">
  416. <label class="field-label">Site context (JSON)</label>
  417. <textarea id="ctx" class="mono" rows="10"
  418. placeholder='{
  419. "address": "24 Clifton Drive, Sorell TAS 7172",
  420. "council": "Sorell Council",
  421. "planning_zones": ["General Residential Zone"],
  422. "planning_codes": ["Parking and Sustainable Transport"],
  423. "use_class": "Health services",
  424. "proposal_summary": "Allied-health clinic..."
  425. }'></textarea>
  426. </div>
  427. <div class="field-group">
  428. <label class="field-label">Project intent (LLM brief)</label>
  429. <textarea id="intent" rows="4"
  430. placeholder="e.g., Construct a two-storey dwelling on a small infill lot within a coastal erosion overlay. Optimise privacy to neighbours and achieve compliant on-site parking."></textarea>
  431. </div>
  432. <hr class="divider">
  433. <!-- Prepared for/by -->
  434. <div class="field-row">
  435. <div>
  436. <label class="field-label">Prepared for</label>
  437. <input type="text" id="preparedFor" placeholder="Client name">
  438. </div>
  439. <div>
  440. <label class="field-label">Prepared by</label>
  441. <input type="text" id="preparedBy" placeholder="Your firm" value="Modulos Design">
  442. </div>
  443. </div>
  444. <hr class="divider">
  445. <!-- Combined -->
  446. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
  447. <label class="field-label" style="margin:0;">Combined markdown</label>
  448. <span id="combinedMeta" style="font-size:0.7rem;color:var(--text-muted);"></span>
  449. </div>
  450. <textarea id="combinedMd" class="mono" rows="14" placeholder="Click Assemble after generating sections…"></textarea>
  451. <div class="export-row">
  452. <button class="btn btn-primary" id="btnDocx" disabled>
  453. <i class="bi bi-file-word"></i> Download .docx
  454. </button>
  455. <button class="btn btn-outline" id="btnGdoc" disabled>
  456. <i class="bi bi-google"></i> Google Doc
  457. </button>
  458. <button class="btn btn-ghost" id="btnDownloadMd2" disabled>
  459. <i class="bi bi-markdown"></i> .md
  460. </button>
  461. </div>
  462. <label class="toggle-row" style="margin-top:8px;">
  463. <span class="toggle-track"><input type="checkbox" id="useGdocTemplate" checked><span class="toggle-knob"></span></span>
  464. <span class="toggle-label">Use Google Doc template if available</span>
  465. </label>
  466. </div>
  467. </div>
  468. </div><!-- end app-body -->
  469. <script>
  470. // TEMPORARY DIAGNOSTICS — remove after confirmed working
  471. console.log('[Builder] sessionStorage key:', sessionStorage.getItem('tpr_builder_ctx'));
  472. console.log('[Builder] all sessionStorage keys:', Object.keys(sessionStorage));
  473. 'use strict';
  474. /* ── Config ─────────────────────────────────────────────────────────── */
  475. const API_BASE = 'https://api.modulos.com.au';
  476. const CTX_STORAGE_KEY = 'tpr_builder_ctx'; // sessionStorage key written by site-report.php
  477. /* ── Helpers ─────────────────────────────────────────────────────────── */
  478. const byId = id => document.getElementById(id);
  479. function setStatus(msg, type = '') {
  480. const bar = byId('statusBar');
  481. bar.className = 'status-bar' + (type ? ' ' + type : '');
  482. bar.innerHTML = type === 'busy'
  483. ? `<span class="spinner"></span> ${msg}`
  484. : `<i class="bi bi-${type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i> ${msg}`;
  485. }
  486. function slugCouncil(s) {
  487. return (s || '').toLowerCase()
  488. .replace(/\bcouncil\b/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
  489. }
  490. /* ── Context: read from sessionStorage (written by site-report.php) ── */
  491. function loadContextFromStorage() {
  492. try {
  493. const raw = localStorage.getItem('tpr_builder_ctx');
  494. console.log('[Builder] localStorage key:', raw?.slice(0, 100) ?? 'null');
  495. if (!raw) return false;
  496. const { ctx, written_at } = JSON.parse(raw);
  497. // Reject if older than 30 minutes — stale from a previous session
  498. if (!written_at || (Date.now() - written_at) > 30 * 60 * 1000) {
  499. localStorage.removeItem('tpr_builder_ctx');
  500. console.log('[Builder] context expired, ignoring');
  501. return false;
  502. }
  503. applyContext(ctx);
  504. return true;
  505. } catch(e) {
  506. console.warn('[Builder] localStorage context parse failed', e);
  507. return false;
  508. }
  509. }
  510. function applyContext(ctx) {
  511. if (!ctx || typeof ctx !== 'object') return;
  512. // Build the JSON for the textarea (exclude map_png to keep it readable)
  513. const { map_png, ...display } = ctx;
  514. byId('ctx').value = JSON.stringify(display, null, 2);
  515. // Project intent
  516. if (ctx.proposal_summary && !byId('intent').value)
  517. byId('intent').value = ctx.proposal_summary;
  518. // Auto-select council
  519. if (ctx.council) {
  520. const slug = slugCouncil(ctx.council);
  521. const sel = byId('council');
  522. // Try to match after councils load; store for later
  523. window._pendingCouncilSlug = slug;
  524. const opt = [...sel.options].find(o => o.value === slug);
  525. if (opt) sel.value = slug;
  526. }
  527. // Show banner
  528. const banner = byId('ctxBanner');
  529. const bannerAddr = byId('ctxBannerAddr');
  530. if (ctx.address) {
  531. bannerAddr.textContent = ctx.address;
  532. banner.classList.remove('hidden');
  533. }
  534. setStatus('Context loaded from property lookup.', '');
  535. }
  536. function clearContext() {
  537. byId('ctx').value = '';
  538. byId('intent').value = '';
  539. byId('ctxBanner').classList.add('hidden');
  540. localStorage.removeItem('tpr_builder_ctx'); // was sessionStorage
  541. setStatus('Context cleared.', '');
  542. }
  543. window.clearContext = clearContext;
  544. /* ── Also accept postMessage as fallback (for same-tab embedded use) ─ */
  545. window.addEventListener('message', ev => {
  546. if (ev.origin !== location.origin) return;
  547. if (ev.data?.type === 'ctx') {
  548. applyContext(ev.data.payload);
  549. try {
  550. localStorage.setItem('tpr_builder_ctx', JSON.stringify({
  551. ctx: ev.data.payload,
  552. written_at: Date.now()
  553. }));
  554. } catch {}
  555. }
  556. });
  557. // Signal to opener that we're ready
  558. try { window.opener?.postMessage({ type: 'builder-ready' }, location.origin); } catch {}
  559. /* ── Sections tree ───────────────────────────────────────────────────── */
  560. const SECTIONS = [
  561. { id:'permit-overview', title:'Permit overview' },
  562. { id:'intro', title:'1 Introduction', children:[
  563. { id:'intro-purpose', title:'1.1 Purpose of report' },
  564. { id:'intro-authority', title:'1.2 Planning authority' },
  565. { id:'intro-controls', title:'1.3 Statutory controls' },
  566. { id:'intro-title', title:'1.4 Title documentation' },
  567. { id:'intro-enquiries', title:'1.5 Enquiries' },
  568. ]},
  569. { id:'proposal', title:'2 Proposal' },
  570. { id:'site', title:'3 Site description', children:[
  571. { id:'site-surrounds', title:'3.1 Site and surrounds' },
  572. ]},
  573. { id:'zoning', title:'4 Zoning assessment', children:[
  574. { id:'zoning-41', title:'4.1 Zoning' },
  575. { id:'zoning-42', title:'4.2 Use status' },
  576. { id:'zoning-43', title:'4.3 Zone purpose' },
  577. { id:'zoning-44', title:'4.4 Use & development standards', children:[
  578. { id:'zoning-441', title:'4.4.1 Discretionary uses' },
  579. { id:'zoning-442', title:'4.4.2 Development standards' },
  580. ]},
  581. ]},
  582. { id:'codes', title:'5 Code assessment', children:[
  583. { id:'code-signs', title:'5.1 Signs Code', children:[
  584. { id:'code-signs-dev', title:'5.1.1 Development standards' },
  585. ]},
  586. { id:'code-parking', title:'5.2 Parking & Sustainable Transport', children:[
  587. { id:'code-parking-use', title:'5.2.1 Use standards' },
  588. { id:'code-parking-bikes', title:'5.2.2 Bicycle parking' },
  589. { id:'code-parking-moto', title:'5.2.3 Motorcycle parking' },
  590. { id:'code-parking-build', title:'5.2.4 Construction of parking' },
  591. { id:'code-parking-design', title:'5.2.5 Design & layout' },
  592. { id:'code-parking-access', title:'5.2.6 Vehicle access' },
  593. { id:'code-parking-ped', title:'5.2.7 Pedestrian access' },
  594. ]},
  595. { id:'code-roadrail', title:'5.3 Road & Railway Assets', children:[
  596. { id:'code-roadrail-traffic', title:'5.3.1 Traffic generation' },
  597. ]},
  598. { id:'code-bushfire', title:'5.4 Bushfire-prone areas', children:[
  599. { id:'code-bushfire-exempt', title:'5.4.1 Exemptions' },
  600. ]},
  601. { id:'code-airports', title:'5.5 Safeguarding of Airports', children:[
  602. { id:'code-airports-sensitive', title:'5.5.1 Sensitive use' },
  603. ]},
  604. ]},
  605. { id:'conclusion', title:'6 Conclusion' },
  606. { id:'appendices', title:'Appendices (A–G placeholders)' },
  607. ];
  608. function renderSectionList() {
  609. const mount = byId('seclist');
  610. function addRow(sec, depth = 0) {
  611. const row = document.createElement('label');
  612. row.className = `sec-item depth-${depth}`;
  613. row.innerHTML = `
  614. <input type="checkbox" id="sw-${sec.id}" ${depth === 0 ? 'checked' : ''}>
  615. <span class="sec-label">${sec.title}</span>
  616. `;
  617. mount.appendChild(row);
  618. (sec.children || []).forEach(ch => addRow(ch, depth + 1));
  619. }
  620. SECTIONS.forEach(s => addRow(s, 0));
  621. }
  622. renderSectionList();
  623. // Toggle all
  624. let allSelected = true;
  625. byId('btnToggleAll').addEventListener('click', () => {
  626. allSelected = !allSelected;
  627. document.querySelectorAll('#seclist input[type=checkbox]')
  628. .forEach(cb => cb.checked = allSelected);
  629. byId('btnToggleAll').textContent = allSelected ? 'Deselect all' : 'Select all';
  630. });
  631. function selectedSections() {
  632. const result = [];
  633. (function walk(items) {
  634. for (const s of items) {
  635. const cb = byId(`sw-${s.id}`);
  636. if (cb?.checked) result.push(s);
  637. if (s.children) walk(s.children);
  638. }
  639. })(SECTIONS);
  640. return result;
  641. }
  642. function findSectionById(id) {
  643. let found = null;
  644. (function walk(items) {
  645. for (const s of items) {
  646. if (s.id === id) { found = s; return; }
  647. if (s.children) walk(s.children);
  648. }
  649. })(SECTIONS);
  650. return found;
  651. }
  652. /* ── Councils ────────────────────────────────────────────────────────── */
  653. async function loadCouncils() {
  654. try {
  655. const res = await fetch(`${API_BASE}/councils`, { cache: 'no-store' });
  656. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  657. const items = await res.json();
  658. const sel = byId('council');
  659. sel.innerHTML = '<option value="">All councils / SPP only</option>' +
  660. items.map(label => {
  661. const val = slugCouncil(label);
  662. return `<option value="${val}">${label}</option>`;
  663. }).join('');
  664. // Apply pending council slug from context
  665. if (window._pendingCouncilSlug) {
  666. const opt = [...sel.options].find(o => o.value === window._pendingCouncilSlug);
  667. if (opt) sel.value = window._pendingCouncilSlug;
  668. window._pendingCouncilSlug = null;
  669. }
  670. } catch(e) {
  671. console.warn('[Builder] Council load failed:', e);
  672. }
  673. }
  674. loadCouncils();
  675. /* ── State ───────────────────────────────────────────────────────────── */
  676. const state = { drafts: {}, order: [], running: false };
  677. /* ── Section output cards ────────────────────────────────────────────── */
  678. function ensureSectionCard(id, title) {
  679. let wrap = byId(`out-${id}`);
  680. if (wrap) return wrap;
  681. // Hide empty state
  682. byId('emptyState').style.display = 'none';
  683. wrap = document.createElement('div');
  684. wrap.className = 'sec-card';
  685. wrap.id = `out-${id}`;
  686. wrap.innerHTML = `
  687. <div class="sec-card-header">
  688. <span class="sec-card-title">${title}</span>
  689. <span class="sec-card-meta" id="meta-${id}">Queued…</span>
  690. <div style="display:flex;gap:5px;flex-shrink:0;">
  691. <button class="btn btn-ghost btn-xs" data-act="regen" data-id="${id}">
  692. <i class="bi bi-arrow-clockwise"></i>
  693. </button>
  694. <button class="btn btn-ghost btn-xs" data-act="copy" data-id="${id}">
  695. <i class="bi bi-clipboard"></i>
  696. </button>
  697. <button class="btn btn-danger btn-xs" data-act="remove" data-id="${id}">
  698. <i class="bi bi-x"></i>
  699. </button>
  700. </div>
  701. </div>
  702. <div class="sec-card-body">
  703. <textarea id="md-${id}" spellcheck="false" rows="8"></textarea>
  704. </div>
  705. <div class="sec-sources" id="src-${id}" style="display:none;"></div>
  706. `;
  707. byId('outputs').appendChild(wrap);
  708. return wrap;
  709. }
  710. byId('outputs').addEventListener('click', async e => {
  711. const btn = e.target.closest('[data-act]');
  712. if (!btn) return;
  713. const id = btn.dataset.id;
  714. const act = btn.dataset.act;
  715. if (act === 'copy') {
  716. const ta = byId(`md-${id}`);
  717. try {
  718. await navigator.clipboard.writeText(ta.value);
  719. btn.innerHTML = '<i class="bi bi-check2"></i>';
  720. setTimeout(() => btn.innerHTML = '<i class="bi bi-clipboard"></i>', 1200);
  721. } catch { ta.select(); document.execCommand('copy'); }
  722. return;
  723. }
  724. if (act === 'regen') {
  725. const sec = findSectionById(id);
  726. if (sec) await generateOne(sec);
  727. return;
  728. }
  729. if (act === 'remove') {
  730. byId(`out-${id}`)?.remove();
  731. delete state.drafts[id];
  732. state.order = state.order.filter(x => x !== id);
  733. if (!Object.keys(state.drafts).length)
  734. byId('emptyState').style.display = '';
  735. return;
  736. }
  737. });
  738. /* Allow editing the textarea and persist changes to state */
  739. byId('outputs').addEventListener('input', e => {
  740. const ta = e.target.closest('textarea');
  741. if (!ta) return;
  742. const id = ta.id.replace('md-', '');
  743. if (id && state.drafts[id] !== undefined) state.drafts[id] = ta.value;
  744. });
  745. /* ── Prompt builder ──────────────────────────────────────────────────── */
  746. function ctxWithIntent() {
  747. let ctx = {};
  748. try { ctx = JSON.parse(byId('ctx').value || '{}'); } catch {}
  749. const intent = (byId('intent').value || '').trim();
  750. if (intent) ctx.project_intent = intent;
  751. return ctx;
  752. }
  753. function buildPrompt(sectionTitle, ctxObj, councilDisplayName, scope, sectionId) {
  754. const hints = [];
  755. if (councilDisplayName) {
  756. if (['local_only','state_plus_local'].includes(scope))
  757. hints.push(`When citing Local Provisions Schedules (LPS), restrict citations to **${councilDisplayName}** only.`);
  758. if (['state_only','state_plus_local'].includes(scope))
  759. hints.push(`You may cite the **Tasmanian Planning Scheme (TPS)** where relevant.`);
  760. }
  761. if (ctxObj?.project_intent) hints.push(`Project intent: ${ctxObj.project_intent}`);
  762. if (ctxObj?.planning_zones?.length) hints.push(`Primary zone(s): ${ctxObj.planning_zones.join(', ')}.`);
  763. if (ctxObj?.planning_codes?.length) hints.push(`Applicable codes: ${ctxObj.planning_codes.join(', ')}.`);
  764. if (ctxObj?.use_class) hints.push(`Use class: ${ctxObj.use_class}. Clarify permissibility.`);
  765. if (ctxObj?.parking) {
  766. const p = ctxObj.parking;
  767. const parts = [];
  768. if (p.cars != null) parts.push(`${p.cars} car spaces`);
  769. if (p.bikes != null) parts.push(`${p.bikes} bicycle spaces`);
  770. if (p.accessible != null) parts.push(`${p.accessible} accessible`);
  771. if (parts.length) hints.push(`Parking proposed: ${parts.join(', ')}.`);
  772. }
  773. const lower = sectionTitle.toLowerCase();
  774. if (lower.includes('discretionary')) hints.push('Explain why the use is discretionary and how performance criteria can be satisfied.');
  775. if (lower.includes('bicycle')) hints.push('State the acceptable solution formula for bicycle parking and test against proposed numbers.');
  776. if (lower.includes('traffic')) hints.push('Reference TIA findings if provided; confirm no adverse impact on road/rail assets.');
  777. const ctxText = ctxObj ? `\nPROJECT CONTEXT (JSON):\n${JSON.stringify(ctxObj)}` : '';
  778. const hintText = hints.length ? `\nSECTION HINTS:\n- ${hints.join('\n- ')}` : '';
  779. return `You are a senior planning consultant preparing a Supporting Planning Report for a Tasmanian application.
  780. Draft the section: **${sectionTitle}**.
  781. Write in professional Tasmanian planning language. Use Markdown headings.
  782. Where relevant, clearly state Acceptable Solutions vs Performance Criteria.
  783. Do not invent facts; if context is missing, state the assumption.
  784. Keep to 150–300 words per sub-section unless a table is helpful.
  785. ${hintText}
  786. ${ctxText}`.trim();
  787. }
  788. /* ── LLM call ────────────────────────────────────────────────────────── */
  789. async function llmDraft(sec) {
  790. const sel = byId('council');
  791. const councilSlug = sel?.value || '';
  792. const councilLabel= sel?.selectedOptions?.[0]?.text || councilSlug || '';
  793. const top_k = parseInt(byId('topk')?.value || '6', 10);
  794. const scope = byId('scopeSelect')?.value || 'state_plus_local';
  795. const ctxObj = ctxWithIntent();
  796. const query = buildPrompt(sec.title, ctxObj, councilLabel, scope, sec.id);
  797. const res = await fetch(`${API_BASE}/ask`, {
  798. method: 'POST',
  799. headers: { 'Content-Type': 'application/json' },
  800. body: JSON.stringify({ query, council: councilSlug || null, top_k, scope, section_id: sec.id || null })
  801. });
  802. const txt = await res.text();
  803. let data = null;
  804. try { data = JSON.parse(txt); } catch {}
  805. if (!res.ok || !data) throw new Error(`LLM HTTP ${res.status} — ${txt.slice(0,200)}`);
  806. return data;
  807. }
  808. async function generateOne(sec) {
  809. ensureSectionCard(sec.id, sec.title);
  810. const metaEl = byId(`meta-${sec.id}`);
  811. const mdEl = byId(`md-${sec.id}`);
  812. const srcEl = byId(`src-${sec.id}`);
  813. metaEl.textContent = 'Drafting…';
  814. mdEl.value = '';
  815. try {
  816. const data = await llmDraft(sec);
  817. const md = (data.answer || '').trim();
  818. if (!md) throw new Error('Empty draft returned');
  819. state.drafts[sec.id] = md;
  820. if (!state.order.includes(sec.id)) state.order.push(sec.id);
  821. mdEl.value = md;
  822. metaEl.textContent = `${md.split(/\s+/).length} words`;
  823. const showSrc = byId('includeSources')?.checked;
  824. if (showSrc && Array.isArray(data.sources) && data.sources.length) {
  825. srcEl.style.display = '';
  826. srcEl.innerHTML = '<span>Sources: </span>' +
  827. data.sources.map(s => `${s.source_file} p.${s.page} (${(s.score ?? 0).toFixed(2)})`).join(' · ');
  828. } else {
  829. srcEl.style.display = 'none';
  830. }
  831. byId('btnAssemble').disabled = false;
  832. } catch(e) {
  833. metaEl.textContent = 'Error';
  834. mdEl.value = `<!-- Error: ${e.message} -->`;
  835. console.error('[Builder] Draft failed', sec.id, e);
  836. }
  837. }
  838. /* ── Generate selected ───────────────────────────────────────────────── */
  839. async function generateSelected() {
  840. if (state.running) return;
  841. const picks = selectedSections();
  842. if (!picks.length) { setStatus('Tick at least one section first.', 'error'); return; }
  843. state.running = true;
  844. byId('btnGenerate').disabled = true;
  845. setStatus(`Generating ${picks.length} section(s)…`, 'busy');
  846. for (let i = 0; i < picks.length; i++) {
  847. setStatus(`(${i+1}/${picks.length}) ${picks[i].title}…`, 'busy');
  848. await generateOne(picks[i]);
  849. }
  850. setStatus(`${picks.length} section(s) complete.`, '');
  851. byId('btnGenerate').disabled = false;
  852. byId('btnAssemble').disabled = false;
  853. state.running = false;
  854. }
  855. byId('btnGenerate').addEventListener('click', generateSelected);
  856. /* ── Clear drafts ────────────────────────────────────────────────────── */
  857. byId('btnClearDrafts').addEventListener('click', () => {
  858. byId('outputs').querySelectorAll('.sec-card').forEach(c => c.remove());
  859. Object.keys(state.drafts).forEach(k => delete state.drafts[k]);
  860. state.order.length = 0;
  861. byId('emptyState').style.display = '';
  862. byId('btnAssemble').disabled = true;
  863. byId('btnCopyAll').disabled = true;
  864. byId('btnDownloadMd').disabled = true;
  865. setStatus('Drafts cleared.', '');
  866. });
  867. /* ── Assemble ────────────────────────────────────────────────────────── */
  868. function assemble() {
  869. if (!state.order.length) { setStatus('No sections generated yet.', 'error'); return; }
  870. const ctx = ctxWithIntent();
  871. const address = ctx.address || '';
  872. const preparedFor = byId('preparedFor').value || ctx.prepared_for || '—';
  873. const preparedBy = byId('preparedBy').value || ctx.prepared_by || 'Modulos Design';
  874. const when = new Date().toLocaleDateString('en-AU', { day:'numeric', month:'long', year:'numeric' });
  875. const intent = ctx.project_intent ? `\n> **Project intent:** ${ctx.project_intent}\n` : '';
  876. const parts = [`# Supporting Planning Report
  877. **Address:** ${address}
  878. Prepared for: **${preparedFor}**
  879. Prepared by: **${preparedBy}**
  880. Date: **${when}**
  881. ${intent}---
  882. `];
  883. state.order.forEach(id => {
  884. const draft = state.drafts[id] || '';
  885. parts.push(draft);
  886. if (!draft.endsWith('\n')) parts.push('\n');
  887. });
  888. const md = parts.join('\n');
  889. byId('combinedMd').value = md;
  890. byId('combinedMeta').textContent = `${state.order.length} sections · ${md.split(/\s+/).length} words`;
  891. byId('btnCopyAll').disabled = false;
  892. byId('btnDownloadMd').disabled = false;
  893. byId('btnDownloadMd2').disabled= false;
  894. byId('btnDocx').disabled = false;
  895. byId('btnGdoc').disabled = false;
  896. setStatus('Markdown assembled.', '');
  897. }
  898. byId('btnAssemble').addEventListener('click', assemble);
  899. /* ── Copy combined ───────────────────────────────────────────────────── */
  900. async function copyCombined() {
  901. const ta = byId('combinedMd');
  902. try {
  903. await navigator.clipboard.writeText(ta.value);
  904. } catch {
  905. ta.select(); document.execCommand('copy');
  906. }
  907. const b = byId('btnCopyAll');
  908. b.innerHTML = '<i class="bi bi-check2"></i> Copied';
  909. setTimeout(() => b.innerHTML = '<i class="bi bi-clipboard"></i> Copy', 1200);
  910. }
  911. byId('btnCopyAll').addEventListener('click', copyCombined);
  912. /* ── Download .md ────────────────────────────────────────────────────── */
  913. function downloadMd() {
  914. const md = byId('combinedMd').value || '';
  915. if (!md) { setStatus('Assemble the report first.', 'error'); return; }
  916. let addr = 'Site';
  917. try { addr = JSON.parse(byId('ctx').value || '{}').address || 'Site'; } catch {}
  918. const fname = `Planning Report — ${addr}.md`.replace(/[^\w\-. ()—]/g, '');
  919. const a = document.createElement('a');
  920. a.href = URL.createObjectURL(new Blob([md], { type:'text/markdown' }));
  921. a.download = fname;
  922. document.body.appendChild(a); a.click(); a.remove();
  923. }
  924. byId('btnDownloadMd').addEventListener('click', downloadMd);
  925. byId('btnDownloadMd2').addEventListener('click', downloadMd);
  926. /* ── Context panel buttons ───────────────────────────────────────────── */
  927. byId('btnPrettyCtx').addEventListener('click', () => {
  928. const ta = byId('ctx');
  929. try { ta.value = JSON.stringify(JSON.parse(ta.value), null, 2); } catch {}
  930. });
  931. byId('btnClearCtx').addEventListener('click', clearContext);
  932. /* ── Build TOC from markdown ─────────────────────────────────────────── */
  933. function buildTocFromMarkdown(md) {
  934. const lines = md.split(/\r?\n/);
  935. const items = [];
  936. for (const line of lines) {
  937. const m = /^(#{1,6})\s+(.*)$/.exec(line);
  938. if (m && m[1].length <= 3) items.push({ level: m[1].length, text: m[2].trim() });
  939. }
  940. if (!items.length) return '<p><em>No headings detected.</em></p>';
  941. return '<div>' + items.map(i => `<div style="margin-left:${(i.level-1)*16}px">${i.text}</div>`).join('') + '</div>';
  942. }
  943. /* ── HTML cover template for export ─────────────────────────────────── */
  944. function htmlCoverAndBody(ctx, htmlBody) {
  945. const address = ctx.address || '';
  946. const preparedFor = byId('preparedFor').value || ctx.prepared_for || '—';
  947. const preparedBy = byId('preparedBy').value || ctx.prepared_by || 'Modulos Design';
  948. const when = new Date().toLocaleDateString('en-AU', { day:'numeric', month:'long', year:'numeric' });
  949. const intent = ctx.project_intent ? `<p><strong>Project intent:</strong> ${ctx.project_intent}</p>` : '';
  950. return `<html><head><meta charset="utf-8">
  951. <style>
  952. body { font-family: Calibri, Arial, sans-serif; font-size: 11pt; color: #111; }
  953. h1,h2,h3 { color: #0f172a; }
  954. .cover { min-height: 200pt; padding: 40pt 0; }
  955. .cover h1 { font-size: 28pt; margin: 0 0 8pt 0; }
  956. .meta { margin-top: 12pt; line-height: 1.8; }
  957. .pb { page-break-after: always; }
  958. .toc h2 { margin-top: 0; }
  959. table { border-collapse: collapse; width: 100%; margin: 1em 0; }
  960. th { background: #f1f5f9; padding: 6pt 8pt; text-align: left; border: 1px solid #cbd5e1; }
  961. td { padding: 5pt 8pt; border: 1px solid #cbd5e1; vertical-align: top; }
  962. </style>
  963. </head><body>
  964. <div class="cover">
  965. <h1>Supporting Planning Report</h1>
  966. <div><strong>Address:</strong> ${address}</div>
  967. <div class="meta">
  968. <div>Prepared for: <strong>${preparedFor}</strong></div>
  969. <div>Prepared by: <strong>${preparedBy}</strong></div>
  970. <div>Date: <strong>${when}</strong></div>
  971. </div>
  972. ${intent}
  973. </div>
  974. <div class="pb"></div>
  975. <div class="toc">
  976. <h2>Contents</h2>
  977. <div id="toc-placeholder"><em>Generated on export</em></div>
  978. </div>
  979. <div class="pb"></div>
  980. ${htmlBody}
  981. </body></html>`;
  982. }
  983. /* ── DOCX export ─────────────────────────────────────────────────────── */
  984. function markdownToHtml(md) {
  985. marked.setOptions({ breaks: true, gfm: true });
  986. return marked.parse(md || '');
  987. }
  988. function downloadDocx() {
  989. const md = byId('combinedMd').value || '';
  990. if (!md) { setStatus('Assemble the report first.', 'error'); return; }
  991. let ctx = {};
  992. try { ctx = JSON.parse(byId('ctx').value || '{}'); } catch {}
  993. const htmlBody = markdownToHtml(md);
  994. const html = htmlCoverAndBody(ctx, htmlBody);
  995. const toc = buildTocFromMarkdown(md);
  996. const final = html.replace('<div id="toc-placeholder"><em>Generated on export</em></div>', toc);
  997. const blob = window.htmlDocx.asBlob(final);
  998. let addr = (ctx.address || 'Planning Report').replace(/[^\w\-. ()]/g, '');
  999. const a = document.createElement('a');
  1000. a.href = URL.createObjectURL(blob);
  1001. a.download = `${addr}.docx`;
  1002. document.body.appendChild(a); a.click(); a.remove();
  1003. }
  1004. async function createServerDocx() {
  1005. const md = byId('combinedMd').value || '';
  1006. if (!md) { setStatus('Assemble first.', 'error'); return; }
  1007. let ctx = {};
  1008. try { ctx = JSON.parse(byId('ctx').value || '{}'); } catch {}
  1009. const res = await fetch('generate_docx.php', {
  1010. method: 'POST', headers: { 'Content-Type': 'application/json' },
  1011. body: JSON.stringify({ markdown: md, context: ctx })
  1012. });
  1013. const out = await res.json().catch(() => null);
  1014. if (!res.ok || !out?.ok) throw new Error(out?.error || `HTTP ${res.status}`);
  1015. window.open(out.url, '_blank');
  1016. }
  1017. byId('btnDocx').addEventListener('click', async () => {
  1018. const btn = byId('btnDocx');
  1019. const orig = btn.innerHTML;
  1020. btn.disabled = true;
  1021. btn.innerHTML = '<span class="spinner"></span> Exporting…';
  1022. try {
  1023. await createServerDocx();
  1024. setStatus('DOCX exported.', '');
  1025. } catch {
  1026. downloadDocx(); // client-side fallback
  1027. } finally {
  1028. btn.disabled = false;
  1029. btn.innerHTML = orig;
  1030. }
  1031. });
  1032. /* ── Google Doc ──────────────────────────────────────────────────────── */
  1033. async function createGoogleDoc() {
  1034. const md = byId('combinedMd').value || '';
  1035. if (!md) { setStatus('Assemble first.', 'error'); return; }
  1036. let ctx = {};
  1037. try { ctx = JSON.parse(byId('ctx').value || '{}'); } catch {}
  1038. const useTemplate = byId('useGdocTemplate').checked;
  1039. const res = await fetch('create_gdoc.php', {
  1040. method: 'POST', headers: { 'Content-Type': 'application/json' },
  1041. body: JSON.stringify({ markdown: md, context: ctx, use_template: useTemplate })
  1042. });
  1043. const out = await res.json().catch(() => null);
  1044. if (!res.ok || !out?.ok) throw new Error(out?.error || `HTTP ${res.status}`);
  1045. window.open(out.url, '_blank');
  1046. }
  1047. byId('btnGdoc').addEventListener('click', async () => {
  1048. const btn = byId('btnGdoc');
  1049. const orig = btn.innerHTML;
  1050. btn.disabled = true;
  1051. btn.innerHTML = '<span class="spinner"></span> Creating…';
  1052. try {
  1053. await createGoogleDoc();
  1054. setStatus('Google Doc created.', '');
  1055. } catch(e) {
  1056. setStatus('Google Doc failed: ' + e.message, 'error');
  1057. } finally {
  1058. btn.disabled = false;
  1059. btn.innerHTML = orig;
  1060. }
  1061. });
  1062. /* ── Boot: load context from sessionStorage ──────────────────────────── */
  1063. document.addEventListener('DOMContentLoaded', () => {
  1064. const loaded = loadContextFromStorage();
  1065. if (!loaded) setStatus('No context loaded — paste JSON or open from Property Lookup.', '');
  1066. });
  1067. </script>
  1068. </body>
  1069. </html>