| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178 |
- <!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Section Builder — Tasmanian Planning Scheme Assistant</title>
- <meta name="description" content="Build a full supporting planning report section by section using AI-generated drafts from the Tasmanian Planning Scheme.">
- <link rel="canonical" href="https://tasplanning.report/section-builder">
- <meta name="robots" content="noindex,follow">
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <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">
- <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
- <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/html-docx-js/dist/html-docx.js"></script>
- <link rel="icon" href="/favicon.ico">
- <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
-
- <style>
- /* ── Design tokens ───────────────────────────────────────────────── */
- :root {
- --bg: #0b0f0e;
- --bg-1: #111614;
- --bg-2: #181e1b;
- --bg-card: #141a17;
- --border: rgba(255,255,255,0.07);
- --border-hover: rgba(255,255,255,0.14);
- --accent: #2ddc8a;
- --accent-dim: rgba(45,220,138,0.10);
- --accent-glow: rgba(45,220,138,0.22);
- --text-primary: #eaf0ec;
- --text-secondary:#8fa899;
- --text-muted: #4f6459;
- --danger: #f08080;
- --warn: #f0c060;
- --info: #60b8f0;
- --serif: 'DM Serif Display', Georgia, serif;
- --sans: 'DM Sans', system-ui, sans-serif;
- --mono: ui-monospace, 'Cascadia Code', Menlo, monospace;
- --radius: 10px;
- --radius-lg: 16px;
- --radius-sm: 5px;
- --transition: 0.16s cubic-bezier(0.4,0,0.2,1);
- }
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- html { height: 100%; }
- body {
- font-family: var(--sans);
- background: var(--bg);
- color: var(--text-primary);
- font-size: 14px;
- line-height: 1.6;
- -webkit-font-smoothing: antialiased;
- height: 100%;
- display: flex;
- flex-direction: column;
- }
- ::selection { background: var(--accent); color: #0b0f0e; }
- /* ── Nav ─────────────────────────────────────────────────────────── */
- .site-nav {
- background: rgba(11,15,14,0.95);
- backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
- position: sticky; top: 0; z-index: 100;
- }
- .nav-inner {
- display: flex; align-items: center; justify-content: space-between;
- padding: 0 20px; height: 54px; gap: 16px;
- }
- .nav-brand {
- display: flex; align-items: center; gap: 9px;
- font-size: 0.85rem; font-weight: 500; color: var(--text-primary);
- text-decoration: none; white-space: nowrap;
- }
- .nav-brand svg { flex-shrink: 0; }
- .nav-crumb {
- display: flex; align-items: center; gap: 6px;
- font-size: 0.8rem; color: var(--text-muted);
- }
- .nav-crumb a { color: var(--text-secondary); text-decoration: none; }
- .nav-crumb a:hover { color: var(--accent); }
- .nav-crumb .sep { color: var(--text-muted); }
- .status-dot {
- width: 7px; height: 7px; border-radius: 50%;
- background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
- display: inline-block; animation: pulse 2.5s ease-in-out infinite;
- }
- @keyframes pulse { 0%,100%{opacity:1}50%{opacity:.4} }
- .nav-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
- .nav-status { display: flex; align-items: center; gap: 5px; font-size: 0.72rem; color: var(--text-muted); }
- /* ── Context banner (shown when context arrives) ─────────────────── */
- .ctx-banner {
- background: var(--accent-dim); border-bottom: 1px solid rgba(45,220,138,0.2);
- padding: 9px 20px; display: flex; align-items: center; gap: 10px;
- font-size: 0.8rem; color: var(--accent); flex-shrink: 0;
- }
- .ctx-banner.hidden { display: none; }
- .ctx-banner strong { color: var(--text-primary); }
- /* ── Layout: 3-panel ─────────────────────────────────────────────── */
- .app-body {
- display: grid;
- grid-template-columns: 280px 1fr 380px;
- flex: 1;
- min-height: 0;
- overflow: hidden;
- }
- @media(max-width: 1100px) {
- .app-body { grid-template-columns: 260px 1fr; }
- .panel-right { display: none; }
- }
- @media(max-width: 720px) {
- .app-body { grid-template-columns: 1fr; }
- .panel-left { display: none; }
- }
- /* ── Panels ──────────────────────────────────────────────────────── */
- .panel {
- overflow-y: auto; display: flex; flex-direction: column;
- border-right: 1px solid var(--border);
- }
- .panel:last-child { border-right: none; }
- .panel-header {
- padding: 14px 16px 10px; border-bottom: 1px solid var(--border);
- flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
- position: sticky; top: 0; background: var(--bg-1); z-index: 10;
- }
- .panel-header h2 {
- font-size: 0.72rem; font-weight: 500; letter-spacing: 0.1em;
- text-transform: uppercase; color: var(--text-muted);
- }
- .panel-body { padding: 14px 16px; flex: 1; }
- /* ── Form elements ───────────────────────────────────────────────── */
- label.field-label {
- display: block; font-size: 0.7rem; font-weight: 500;
- letter-spacing: 0.08em; text-transform: uppercase;
- color: var(--text-muted); margin-bottom: 5px;
- }
- textarea, input[type=text], input[type=number], select {
- width: 100%;
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: var(--radius-sm); color: var(--text-primary);
- font-family: var(--sans); font-size: 0.82rem; outline: none;
- transition: border-color var(--transition);
- padding: 8px 10px;
- }
- textarea { resize: vertical; min-height: 80px; line-height: 1.55; }
- textarea.mono { font-family: var(--mono); font-size: 0.75rem; }
- textarea:focus, input:focus, select:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 2px var(--accent-dim);
- }
- textarea::placeholder, input::placeholder { color: var(--text-muted); }
- select option { background: var(--bg-2); }
- .field-group { margin-bottom: 14px; }
- .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
- /* ── Toggle switch ───────────────────────────────────────────────── */
- .toggle-row {
- display: flex; align-items: center; gap: 8px;
- margin-bottom: 8px; cursor: pointer;
- }
- .toggle-track {
- position: relative; width: 32px; height: 18px;
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: 999px; flex-shrink: 0;
- transition: background var(--transition), border-color var(--transition);
- }
- .toggle-track:has(input:checked) { background: var(--accent); border-color: var(--accent); }
- .toggle-track input {
- position: absolute; opacity: 0; width: 100%; height: 100%;
- cursor: pointer; margin: 0; border: none; background: none;
- }
- .toggle-knob {
- position: absolute; top: 2px; left: 2px;
- width: 12px; height: 12px; background: #fff;
- border-radius: 50%; pointer-events: none;
- transition: transform var(--transition);
- }
- .toggle-track:has(input:checked) .toggle-knob { transform: translateX(14px); }
- .toggle-label { font-size: 0.78rem; color: var(--text-secondary); }
- /* ── Section checkboxes ──────────────────────────────────────────── */
- .sec-item {
- display: flex; align-items: center; gap: 7px;
- padding: 4px 0; cursor: pointer;
- }
- .sec-item input[type=checkbox] {
- width: 14px; height: 14px; flex-shrink: 0;
- accent-color: var(--accent); cursor: pointer;
- background: var(--bg-2); border: 1px solid var(--border);
- }
- .sec-label { font-size: 0.8rem; color: var(--text-secondary); line-height: 1.3; }
- .sec-item.depth-0 .sec-label { color: var(--text-primary); font-weight: 500; }
- .sec-item.depth-1 { padding-left: 16px; }
- .sec-item.depth-2 { padding-left: 32px; }
- /* ── Buttons ─────────────────────────────────────────────────────── */
- .btn {
- display: inline-flex; align-items: center; gap: 6px;
- padding: 8px 16px; border-radius: var(--radius);
- font-family: var(--sans); font-size: 0.8rem; font-weight: 500;
- cursor: pointer; transition: all var(--transition); border: none;
- white-space: nowrap; text-decoration: none;
- }
- .btn-primary { background: var(--accent); color: #0b0f0e; }
- .btn-primary:hover:not(:disabled) { background: #3bf59a; transform: translateY(-1px); }
- .btn-primary:disabled { opacity: 0.35; cursor: not-allowed; }
- .btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-hover); }
- .btn-outline:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
- .btn-outline:disabled { opacity: 0.3; cursor: not-allowed; }
- .btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
- .btn-ghost:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-secondary); }
- .btn-ghost:disabled { opacity: 0.25; cursor: not-allowed; }
- .btn-danger { background: transparent; color: var(--danger); border: 1px solid rgba(240,128,128,0.3); }
- .btn-danger:hover { background: rgba(240,128,128,0.08); }
- .btn-xs { padding: 4px 10px; font-size: 0.72rem; border-radius: var(--radius-sm); }
- .btn-block { width: 100%; justify-content: center; }
- .btn-row { display: flex; flex-wrap: wrap; gap: 6px; }
- /* ── Status bar ──────────────────────────────────────────────────── */
- .status-bar {
- padding: 8px 16px; border-top: 1px solid var(--border);
- font-size: 0.75rem; color: var(--text-muted); flex-shrink: 0;
- display: flex; align-items: center; gap: 8px; min-height: 34px;
- }
- .status-bar.busy { color: var(--info); }
- .status-bar.error { color: var(--danger); }
- .spinner {
- width: 13px; height: 13px;
- border: 2px solid var(--border); border-top-color: var(--accent);
- border-radius: 50%; animation: spin .65s linear infinite; flex-shrink: 0;
- }
- @keyframes spin { to { transform: rotate(360deg); } }
- /* ── Section output cards ────────────────────────────────────────── */
- .sec-card {
- background: var(--bg-card); border: 1px solid var(--border);
- border-radius: var(--radius); margin-bottom: 12px;
- animation: fadeUp .25s ease;
- }
- @keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
- .sec-card-header {
- display: flex; align-items: center; justify-content: space-between;
- padding: 10px 14px; border-bottom: 1px solid var(--border);
- gap: 10px;
- }
- .sec-card-title { font-size: 0.82rem; font-weight: 500; color: var(--text-primary); }
- .sec-card-meta { font-size: 0.7rem; color: var(--text-muted); margin: 0 6px 0 auto; }
- .sec-card-body { padding: 10px 14px; }
- .sec-card textarea {
- min-height: 140px; background: var(--bg-2);
- font-family: var(--mono); font-size: 0.73rem; line-height: 1.5;
- }
- .sec-sources {
- font-size: 0.7rem; color: var(--text-muted);
- padding: 6px 14px 10px; border-top: 1px solid var(--border);
- }
- .sec-sources span { color: var(--text-secondary); }
- /* ── Combined / right panel ──────────────────────────────────────── */
- .combined-wrap { padding: 14px 16px; }
- .combined-wrap textarea {
- min-height: 300px; font-family: var(--mono); font-size: 0.73rem;
- line-height: 1.5; background: var(--bg-2);
- }
- .export-row {
- display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px;
- }
- /* ── Divider ─────────────────────────────────────────────────────── */
- .divider { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
- /* ── Empty state ─────────────────────────────────────────────────── */
- .empty-state {
- display: flex; flex-direction: column; align-items: center;
- justify-content: center; gap: 10px; height: 200px;
- color: var(--text-muted); font-size: 0.8rem; text-align: center;
- padding: 20px;
- }
- .empty-state i { font-size: 2rem; opacity: 0.3; }
- /* ── Scrollbar ───────────────────────────────────────────────────── */
- ::-webkit-scrollbar { width: 5px; height: 5px; }
- ::-webkit-scrollbar-track { background: transparent; }
- ::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
- </style>
- <!-- Google tag (gtag.js) -->
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-LWEHQVCWEZ"></script>
- <script>
- window.dataLayer = window.dataLayer || [];
- function gtag(){dataLayer.push(arguments);}
- gtag('js', new Date());
- gtag('config', 'G-LWEHQVCWEZ');
- </script>
-
- <!-- Google Tag Manager -->
- <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
- new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
- j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
- 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
- })(window,document,'script','dataLayer','GTM-M5PFLGZT');</script>
- <!-- End Google Tag Manager -->
- </head>
- <body>
- <!-- Google Tag Manager (noscript) -->
- <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M5PFLGZT"
- height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
- <!-- End Google Tag Manager (noscript) -->
- <!-- ── Nav ──────────────────────────────────────────────────────────── -->
- <nav class="site-nav">
- <div class="nav-inner">
- <a class="nav-brand" href="/">
- <svg width="22" height="22" viewBox="0 0 28 28" fill="none">
- <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
- <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
- </svg>
- Tas Planning
- </a>
- <div class="nav-crumb">
- <a href="/site-report.php">Property lookup</a>
- <span class="sep">›</span>
- <span style="color:var(--text-secondary);">Section builder</span>
- </div>
- <div class="nav-right">
- <div class="nav-status">
- <span class="status-dot"></span>
- <span class="nav-status-text">API live</span>
- </div>
- </div>
- </div>
- </nav>
- <!-- ── Context banner ────────────────────────────────────────────────── -->
- <div class="ctx-banner hidden" id="ctxBanner">
- <i class="bi bi-check-circle-fill"></i>
- Context loaded: <strong id="ctxBannerAddr"></strong>
- <button class="btn btn-ghost btn-xs" style="margin-left:auto;" onclick="clearContext()">
- <i class="bi bi-x"></i> Clear
- </button>
- </div>
- <!-- ── App body ──────────────────────────────────────────────────────── -->
- <div class="app-body">
- <!-- ── LEFT: Sections + controls ─────────────────────────────────── -->
- <div class="panel panel-left" id="panelLeft">
- <div class="panel-header">
- <h2>Report sections</h2>
- <button class="btn btn-ghost btn-xs" id="btnToggleAll">Select all</button>
- </div>
- <div class="panel-body">
- <div id="seclist"></div>
- <hr class="divider">
- <!-- Generate controls -->
- <div class="field-group">
- <label class="field-label">Council scope</label>
- <select id="council">
- <option value="">All councils / SPP only</option>
- </select>
- </div>
- <div class="field-row">
- <div>
- <label class="field-label">RAG top-k</label>
- <input type="number" id="topk" value="6" min="3" max="16">
- </div>
- <div>
- <label class="field-label">Scope</label>
- <select id="scopeSelect">
- <option value="state_plus_local">State + local</option>
- <option value="state_only">State only</option>
- <option value="local_only">Local only</option>
- <option value="any">Any</option>
- </select>
- </div>
- </div>
- <label class="toggle-row">
- <span class="toggle-track"><input type="checkbox" id="includeSources" checked><span class="toggle-knob"></span></span>
- <span class="toggle-label">Show sources under each section</span>
- </label>
- <hr class="divider">
- <div class="btn-row" style="margin-bottom:8px;">
- <button class="btn btn-primary" id="btnGenerate" style="flex:1;">
- <i class="bi bi-stars"></i> Generate selected
- </button>
- </div>
- <div class="btn-row">
- <button class="btn btn-outline" id="btnAssemble" disabled>
- <i class="bi bi-collection"></i> Assemble
- </button>
- <button class="btn btn-ghost" id="btnCopyAll" disabled>
- <i class="bi bi-clipboard"></i> Copy
- </button>
- <button class="btn btn-ghost" id="btnDownloadMd" disabled>
- <i class="bi bi-file-text"></i> .md
- </button>
- </div>
- </div>
- <div class="status-bar" id="statusBar">
- <i class="bi bi-info-circle"></i> Ready
- </div>
- </div>
- <!-- ── CENTRE: Section drafts ─────────────────────────────────────── -->
- <div class="panel panel-centre">
- <div class="panel-header">
- <h2>Section drafts</h2>
- <div style="display:flex;gap:6px;">
- <button class="btn btn-ghost btn-xs" id="btnClearDrafts">Clear all</button>
- </div>
- </div>
- <div style="padding:14px 16px;flex:1;" id="outputs">
- <div class="empty-state" id="emptyState">
- <i class="bi bi-file-earmark-text"></i>
- <div>Select sections on the left and click<br><strong style="color:var(--text-secondary)">Generate selected</strong> to start drafting.</div>
- </div>
- </div>
- </div>
- <!-- ── RIGHT: Context + Combined ─────────────────────────────────── -->
- <div class="panel panel-right" id="panelRight">
- <!-- Context -->
- <div class="panel-header">
- <h2>Project context</h2>
- <div style="display:flex;gap:6px;">
- <button class="btn btn-ghost btn-xs" id="btnPrettyCtx">Format</button>
- <button class="btn btn-ghost btn-xs" id="btnClearCtx">Clear</button>
- </div>
- </div>
- <div class="panel-body">
- <div class="field-group">
- <label class="field-label">Site context (JSON)</label>
- <textarea id="ctx" class="mono" rows="10"
- placeholder='{
- "address": "24 Clifton Drive, Sorell TAS 7172",
- "council": "Sorell Council",
- "planning_zones": ["General Residential Zone"],
- "planning_codes": ["Parking and Sustainable Transport"],
- "use_class": "Health services",
- "proposal_summary": "Allied-health clinic..."
- }'></textarea>
- </div>
- <div class="field-group">
- <label class="field-label">Project intent (LLM brief)</label>
- <textarea id="intent" rows="4"
- 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>
- </div>
- <hr class="divider">
- <!-- Prepared for/by -->
- <div class="field-row">
- <div>
- <label class="field-label">Prepared for</label>
- <input type="text" id="preparedFor" placeholder="Client name">
- </div>
- <div>
- <label class="field-label">Prepared by</label>
- <input type="text" id="preparedBy" placeholder="Your firm" value="Modulos Design">
- </div>
- </div>
- <hr class="divider">
- <!-- Combined -->
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
- <label class="field-label" style="margin:0;">Combined markdown</label>
- <span id="combinedMeta" style="font-size:0.7rem;color:var(--text-muted);"></span>
- </div>
- <textarea id="combinedMd" class="mono" rows="14" placeholder="Click Assemble after generating sections…"></textarea>
- <div class="export-row">
- <button class="btn btn-primary" id="btnDocx" disabled>
- <i class="bi bi-file-word"></i> Download .docx
- </button>
- <button class="btn btn-outline" id="btnGdoc" disabled>
- <i class="bi bi-google"></i> Google Doc
- </button>
- <button class="btn btn-ghost" id="btnDownloadMd2" disabled>
- <i class="bi bi-markdown"></i> .md
- </button>
- </div>
- <label class="toggle-row" style="margin-top:8px;">
- <span class="toggle-track"><input type="checkbox" id="useGdocTemplate" checked><span class="toggle-knob"></span></span>
- <span class="toggle-label">Use Google Doc template if available</span>
- </label>
- </div>
- </div>
- </div><!-- end app-body -->
- <script>
-
- // TEMPORARY DIAGNOSTICS — remove after confirmed working
- console.log('[Builder] sessionStorage key:', sessionStorage.getItem('tpr_builder_ctx'));
- console.log('[Builder] all sessionStorage keys:', Object.keys(sessionStorage));
- 'use strict';
- /* ── Config ─────────────────────────────────────────────────────────── */
- const API_BASE = 'https://api.modulos.com.au';
- const CTX_STORAGE_KEY = 'tpr_builder_ctx'; // sessionStorage key written by site-report.php
- /* ── Helpers ─────────────────────────────────────────────────────────── */
- const byId = id => document.getElementById(id);
- function setStatus(msg, type = '') {
- const bar = byId('statusBar');
- bar.className = 'status-bar' + (type ? ' ' + type : '');
- bar.innerHTML = type === 'busy'
- ? `<span class="spinner"></span> ${msg}`
- : `<i class="bi bi-${type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i> ${msg}`;
- }
- function slugCouncil(s) {
- return (s || '').toLowerCase()
- .replace(/\bcouncil\b/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
- }
- /* ── Context: read from sessionStorage (written by site-report.php) ── */
- function loadContextFromStorage() {
- try {
- const raw = localStorage.getItem('tpr_builder_ctx');
- console.log('[Builder] localStorage key:', raw?.slice(0, 100) ?? 'null');
- if (!raw) return false;
- const { ctx, written_at } = JSON.parse(raw);
- // Reject if older than 30 minutes — stale from a previous session
- if (!written_at || (Date.now() - written_at) > 30 * 60 * 1000) {
- localStorage.removeItem('tpr_builder_ctx');
- console.log('[Builder] context expired, ignoring');
- return false;
- }
- applyContext(ctx);
- return true;
- } catch(e) {
- console.warn('[Builder] localStorage context parse failed', e);
- return false;
- }
- }
- function applyContext(ctx) {
- if (!ctx || typeof ctx !== 'object') return;
- // Build the JSON for the textarea (exclude map_png to keep it readable)
- const { map_png, ...display } = ctx;
- byId('ctx').value = JSON.stringify(display, null, 2);
- // Project intent
- if (ctx.proposal_summary && !byId('intent').value)
- byId('intent').value = ctx.proposal_summary;
- // Auto-select council
- if (ctx.council) {
- const slug = slugCouncil(ctx.council);
- const sel = byId('council');
- // Try to match after councils load; store for later
- window._pendingCouncilSlug = slug;
- const opt = [...sel.options].find(o => o.value === slug);
- if (opt) sel.value = slug;
- }
- // Show banner
- const banner = byId('ctxBanner');
- const bannerAddr = byId('ctxBannerAddr');
- if (ctx.address) {
- bannerAddr.textContent = ctx.address;
- banner.classList.remove('hidden');
- }
- setStatus('Context loaded from property lookup.', '');
- }
- function clearContext() {
- byId('ctx').value = '';
- byId('intent').value = '';
- byId('ctxBanner').classList.add('hidden');
- localStorage.removeItem('tpr_builder_ctx'); // was sessionStorage
- setStatus('Context cleared.', '');
- }
- window.clearContext = clearContext;
- /* ── Also accept postMessage as fallback (for same-tab embedded use) ─ */
- window.addEventListener('message', ev => {
- if (ev.origin !== location.origin) return;
- if (ev.data?.type === 'ctx') {
- applyContext(ev.data.payload);
- try {
- localStorage.setItem('tpr_builder_ctx', JSON.stringify({
- ctx: ev.data.payload,
- written_at: Date.now()
- }));
- } catch {}
- }
- });
- // Signal to opener that we're ready
- try { window.opener?.postMessage({ type: 'builder-ready' }, location.origin); } catch {}
- /* ── Sections tree ───────────────────────────────────────────────────── */
- const SECTIONS = [
- { id:'permit-overview', title:'Permit overview' },
- { id:'intro', title:'1 Introduction', children:[
- { id:'intro-purpose', title:'1.1 Purpose of report' },
- { id:'intro-authority', title:'1.2 Planning authority' },
- { id:'intro-controls', title:'1.3 Statutory controls' },
- { id:'intro-title', title:'1.4 Title documentation' },
- { id:'intro-enquiries', title:'1.5 Enquiries' },
- ]},
- { id:'proposal', title:'2 Proposal' },
- { id:'site', title:'3 Site description', children:[
- { id:'site-surrounds', title:'3.1 Site and surrounds' },
- ]},
- { id:'zoning', title:'4 Zoning assessment', children:[
- { id:'zoning-41', title:'4.1 Zoning' },
- { id:'zoning-42', title:'4.2 Use status' },
- { id:'zoning-43', title:'4.3 Zone purpose' },
- { id:'zoning-44', title:'4.4 Use & development standards', children:[
- { id:'zoning-441', title:'4.4.1 Discretionary uses' },
- { id:'zoning-442', title:'4.4.2 Development standards' },
- ]},
- ]},
- { id:'codes', title:'5 Code assessment', children:[
- { id:'code-signs', title:'5.1 Signs Code', children:[
- { id:'code-signs-dev', title:'5.1.1 Development standards' },
- ]},
- { id:'code-parking', title:'5.2 Parking & Sustainable Transport', children:[
- { id:'code-parking-use', title:'5.2.1 Use standards' },
- { id:'code-parking-bikes', title:'5.2.2 Bicycle parking' },
- { id:'code-parking-moto', title:'5.2.3 Motorcycle parking' },
- { id:'code-parking-build', title:'5.2.4 Construction of parking' },
- { id:'code-parking-design', title:'5.2.5 Design & layout' },
- { id:'code-parking-access', title:'5.2.6 Vehicle access' },
- { id:'code-parking-ped', title:'5.2.7 Pedestrian access' },
- ]},
- { id:'code-roadrail', title:'5.3 Road & Railway Assets', children:[
- { id:'code-roadrail-traffic', title:'5.3.1 Traffic generation' },
- ]},
- { id:'code-bushfire', title:'5.4 Bushfire-prone areas', children:[
- { id:'code-bushfire-exempt', title:'5.4.1 Exemptions' },
- ]},
- { id:'code-airports', title:'5.5 Safeguarding of Airports', children:[
- { id:'code-airports-sensitive', title:'5.5.1 Sensitive use' },
- ]},
- ]},
- { id:'conclusion', title:'6 Conclusion' },
- { id:'appendices', title:'Appendices (A–G placeholders)' },
- ];
- function renderSectionList() {
- const mount = byId('seclist');
- function addRow(sec, depth = 0) {
- const row = document.createElement('label');
- row.className = `sec-item depth-${depth}`;
- row.innerHTML = `
- <input type="checkbox" id="sw-${sec.id}" ${depth === 0 ? 'checked' : ''}>
- <span class="sec-label">${sec.title}</span>
- `;
- mount.appendChild(row);
- (sec.children || []).forEach(ch => addRow(ch, depth + 1));
- }
- SECTIONS.forEach(s => addRow(s, 0));
- }
- renderSectionList();
- // Toggle all
- let allSelected = true;
- byId('btnToggleAll').addEventListener('click', () => {
- allSelected = !allSelected;
- document.querySelectorAll('#seclist input[type=checkbox]')
- .forEach(cb => cb.checked = allSelected);
- byId('btnToggleAll').textContent = allSelected ? 'Deselect all' : 'Select all';
- });
- function selectedSections() {
- const result = [];
- (function walk(items) {
- for (const s of items) {
- const cb = byId(`sw-${s.id}`);
- if (cb?.checked) result.push(s);
- if (s.children) walk(s.children);
- }
- })(SECTIONS);
- return result;
- }
- function findSectionById(id) {
- let found = null;
- (function walk(items) {
- for (const s of items) {
- if (s.id === id) { found = s; return; }
- if (s.children) walk(s.children);
- }
- })(SECTIONS);
- return found;
- }
- /* ── Councils ────────────────────────────────────────────────────────── */
- async function loadCouncils() {
- try {
- const res = await fetch(`${API_BASE}/councils`, { cache: 'no-store' });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const items = await res.json();
- const sel = byId('council');
- sel.innerHTML = '<option value="">All councils / SPP only</option>' +
- items.map(label => {
- const val = slugCouncil(label);
- return `<option value="${val}">${label}</option>`;
- }).join('');
- // Apply pending council slug from context
- if (window._pendingCouncilSlug) {
- const opt = [...sel.options].find(o => o.value === window._pendingCouncilSlug);
- if (opt) sel.value = window._pendingCouncilSlug;
- window._pendingCouncilSlug = null;
- }
- } catch(e) {
- console.warn('[Builder] Council load failed:', e);
- }
- }
- loadCouncils();
- /* ── State ───────────────────────────────────────────────────────────── */
- const state = { drafts: {}, order: [], running: false };
- /* ── Section output cards ────────────────────────────────────────────── */
- function ensureSectionCard(id, title) {
- let wrap = byId(`out-${id}`);
- if (wrap) return wrap;
- // Hide empty state
- byId('emptyState').style.display = 'none';
- wrap = document.createElement('div');
- wrap.className = 'sec-card';
- wrap.id = `out-${id}`;
- wrap.innerHTML = `
- <div class="sec-card-header">
- <span class="sec-card-title">${title}</span>
- <span class="sec-card-meta" id="meta-${id}">Queued…</span>
- <div style="display:flex;gap:5px;flex-shrink:0;">
- <button class="btn btn-ghost btn-xs" data-act="regen" data-id="${id}">
- <i class="bi bi-arrow-clockwise"></i>
- </button>
- <button class="btn btn-ghost btn-xs" data-act="copy" data-id="${id}">
- <i class="bi bi-clipboard"></i>
- </button>
- <button class="btn btn-danger btn-xs" data-act="remove" data-id="${id}">
- <i class="bi bi-x"></i>
- </button>
- </div>
- </div>
- <div class="sec-card-body">
- <textarea id="md-${id}" spellcheck="false" rows="8"></textarea>
- </div>
- <div class="sec-sources" id="src-${id}" style="display:none;"></div>
- `;
- byId('outputs').appendChild(wrap);
- return wrap;
- }
- byId('outputs').addEventListener('click', async e => {
- const btn = e.target.closest('[data-act]');
- if (!btn) return;
- const id = btn.dataset.id;
- const act = btn.dataset.act;
- if (act === 'copy') {
- const ta = byId(`md-${id}`);
- try {
- await navigator.clipboard.writeText(ta.value);
- btn.innerHTML = '<i class="bi bi-check2"></i>';
- setTimeout(() => btn.innerHTML = '<i class="bi bi-clipboard"></i>', 1200);
- } catch { ta.select(); document.execCommand('copy'); }
- return;
- }
- if (act === 'regen') {
- const sec = findSectionById(id);
- if (sec) await generateOne(sec);
- return;
- }
- if (act === 'remove') {
- byId(`out-${id}`)?.remove();
- delete state.drafts[id];
- state.order = state.order.filter(x => x !== id);
- if (!Object.keys(state.drafts).length)
- byId('emptyState').style.display = '';
- return;
- }
- });
- /* Allow editing the textarea and persist changes to state */
- byId('outputs').addEventListener('input', e => {
- const ta = e.target.closest('textarea');
- if (!ta) return;
- const id = ta.id.replace('md-', '');
- if (id && state.drafts[id] !== undefined) state.drafts[id] = ta.value;
- });
- /* ── Prompt builder ──────────────────────────────────────────────────── */
- function ctxWithIntent() {
- let ctx = {};
- try { ctx = JSON.parse(byId('ctx').value || '{}'); } catch {}
- const intent = (byId('intent').value || '').trim();
- if (intent) ctx.project_intent = intent;
- return ctx;
- }
- function buildPrompt(sectionTitle, ctxObj, councilDisplayName, scope, sectionId) {
- const hints = [];
- if (councilDisplayName) {
- if (['local_only','state_plus_local'].includes(scope))
- hints.push(`When citing Local Provisions Schedules (LPS), restrict citations to **${councilDisplayName}** only.`);
- if (['state_only','state_plus_local'].includes(scope))
- hints.push(`You may cite the **Tasmanian Planning Scheme (TPS)** where relevant.`);
- }
- if (ctxObj?.project_intent) hints.push(`Project intent: ${ctxObj.project_intent}`);
- if (ctxObj?.planning_zones?.length) hints.push(`Primary zone(s): ${ctxObj.planning_zones.join(', ')}.`);
- if (ctxObj?.planning_codes?.length) hints.push(`Applicable codes: ${ctxObj.planning_codes.join(', ')}.`);
- if (ctxObj?.use_class) hints.push(`Use class: ${ctxObj.use_class}. Clarify permissibility.`);
- if (ctxObj?.parking) {
- const p = ctxObj.parking;
- const parts = [];
- if (p.cars != null) parts.push(`${p.cars} car spaces`);
- if (p.bikes != null) parts.push(`${p.bikes} bicycle spaces`);
- if (p.accessible != null) parts.push(`${p.accessible} accessible`);
- if (parts.length) hints.push(`Parking proposed: ${parts.join(', ')}.`);
- }
- const lower = sectionTitle.toLowerCase();
- if (lower.includes('discretionary')) hints.push('Explain why the use is discretionary and how performance criteria can be satisfied.');
- if (lower.includes('bicycle')) hints.push('State the acceptable solution formula for bicycle parking and test against proposed numbers.');
- if (lower.includes('traffic')) hints.push('Reference TIA findings if provided; confirm no adverse impact on road/rail assets.');
- const ctxText = ctxObj ? `\nPROJECT CONTEXT (JSON):\n${JSON.stringify(ctxObj)}` : '';
- const hintText = hints.length ? `\nSECTION HINTS:\n- ${hints.join('\n- ')}` : '';
- return `You are a senior planning consultant preparing a Supporting Planning Report for a Tasmanian application.
- Draft the section: **${sectionTitle}**.
- Write in professional Tasmanian planning language. Use Markdown headings.
- Where relevant, clearly state Acceptable Solutions vs Performance Criteria.
- Do not invent facts; if context is missing, state the assumption.
- Keep to 150–300 words per sub-section unless a table is helpful.
- ${hintText}
- ${ctxText}`.trim();
- }
- /* ── LLM call ────────────────────────────────────────────────────────── */
- async function llmDraft(sec) {
- const sel = byId('council');
- const councilSlug = sel?.value || '';
- const councilLabel= sel?.selectedOptions?.[0]?.text || councilSlug || '';
- const top_k = parseInt(byId('topk')?.value || '6', 10);
- const scope = byId('scopeSelect')?.value || 'state_plus_local';
- const ctxObj = ctxWithIntent();
- const query = buildPrompt(sec.title, ctxObj, councilLabel, scope, sec.id);
- const res = await fetch(`${API_BASE}/ask`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ query, council: councilSlug || null, top_k, scope, section_id: sec.id || null })
- });
- const txt = await res.text();
- let data = null;
- try { data = JSON.parse(txt); } catch {}
- if (!res.ok || !data) throw new Error(`LLM HTTP ${res.status} — ${txt.slice(0,200)}`);
- return data;
- }
- async function generateOne(sec) {
- ensureSectionCard(sec.id, sec.title);
- const metaEl = byId(`meta-${sec.id}`);
- const mdEl = byId(`md-${sec.id}`);
- const srcEl = byId(`src-${sec.id}`);
- metaEl.textContent = 'Drafting…';
- mdEl.value = '';
- try {
- const data = await llmDraft(sec);
- const md = (data.answer || '').trim();
- if (!md) throw new Error('Empty draft returned');
- state.drafts[sec.id] = md;
- if (!state.order.includes(sec.id)) state.order.push(sec.id);
- mdEl.value = md;
- metaEl.textContent = `${md.split(/\s+/).length} words`;
- const showSrc = byId('includeSources')?.checked;
- if (showSrc && Array.isArray(data.sources) && data.sources.length) {
- srcEl.style.display = '';
- srcEl.innerHTML = '<span>Sources: </span>' +
- data.sources.map(s => `${s.source_file} p.${s.page} (${(s.score ?? 0).toFixed(2)})`).join(' · ');
- } else {
- srcEl.style.display = 'none';
- }
- byId('btnAssemble').disabled = false;
- } catch(e) {
- metaEl.textContent = 'Error';
- mdEl.value = `<!-- Error: ${e.message} -->`;
- console.error('[Builder] Draft failed', sec.id, e);
- }
- }
- /* ── Generate selected ───────────────────────────────────────────────── */
- async function generateSelected() {
- if (state.running) return;
- const picks = selectedSections();
- if (!picks.length) { setStatus('Tick at least one section first.', 'error'); return; }
- state.running = true;
- byId('btnGenerate').disabled = true;
- setStatus(`Generating ${picks.length} section(s)…`, 'busy');
- for (let i = 0; i < picks.length; i++) {
- setStatus(`(${i+1}/${picks.length}) ${picks[i].title}…`, 'busy');
- await generateOne(picks[i]);
- }
- setStatus(`${picks.length} section(s) complete.`, '');
- byId('btnGenerate').disabled = false;
- byId('btnAssemble').disabled = false;
- state.running = false;
- }
- byId('btnGenerate').addEventListener('click', generateSelected);
- /* ── Clear drafts ────────────────────────────────────────────────────── */
- byId('btnClearDrafts').addEventListener('click', () => {
- byId('outputs').querySelectorAll('.sec-card').forEach(c => c.remove());
- Object.keys(state.drafts).forEach(k => delete state.drafts[k]);
- state.order.length = 0;
- byId('emptyState').style.display = '';
- byId('btnAssemble').disabled = true;
- byId('btnCopyAll').disabled = true;
- byId('btnDownloadMd').disabled = true;
- setStatus('Drafts cleared.', '');
- });
- /* ── Assemble ────────────────────────────────────────────────────────── */
- function assemble() {
- if (!state.order.length) { setStatus('No sections generated yet.', 'error'); return; }
- const ctx = ctxWithIntent();
- const address = ctx.address || '';
- const preparedFor = byId('preparedFor').value || ctx.prepared_for || '—';
- const preparedBy = byId('preparedBy').value || ctx.prepared_by || 'Modulos Design';
- const when = new Date().toLocaleDateString('en-AU', { day:'numeric', month:'long', year:'numeric' });
- const intent = ctx.project_intent ? `\n> **Project intent:** ${ctx.project_intent}\n` : '';
- const parts = [`# Supporting Planning Report
- **Address:** ${address}
- Prepared for: **${preparedFor}**
- Prepared by: **${preparedBy}**
- Date: **${when}**
- ${intent}---
- `];
- state.order.forEach(id => {
- const draft = state.drafts[id] || '';
- parts.push(draft);
- if (!draft.endsWith('\n')) parts.push('\n');
- });
- const md = parts.join('\n');
- byId('combinedMd').value = md;
- byId('combinedMeta').textContent = `${state.order.length} sections · ${md.split(/\s+/).length} words`;
- byId('btnCopyAll').disabled = false;
- byId('btnDownloadMd').disabled = false;
- byId('btnDownloadMd2').disabled= false;
- byId('btnDocx').disabled = false;
- byId('btnGdoc').disabled = false;
- setStatus('Markdown assembled.', '');
- }
- byId('btnAssemble').addEventListener('click', assemble);
- /* ── Copy combined ───────────────────────────────────────────────────── */
- async function copyCombined() {
- const ta = byId('combinedMd');
- try {
- await navigator.clipboard.writeText(ta.value);
- } catch {
- ta.select(); document.execCommand('copy');
- }
- const b = byId('btnCopyAll');
- b.innerHTML = '<i class="bi bi-check2"></i> Copied';
- setTimeout(() => b.innerHTML = '<i class="bi bi-clipboard"></i> Copy', 1200);
- }
- byId('btnCopyAll').addEventListener('click', copyCombined);
- /* ── Download .md ────────────────────────────────────────────────────── */
- function downloadMd() {
- const md = byId('combinedMd').value || '';
- if (!md) { setStatus('Assemble the report first.', 'error'); return; }
- let addr = 'Site';
- try { addr = JSON.parse(byId('ctx').value || '{}').address || 'Site'; } catch {}
- const fname = `Planning Report — ${addr}.md`.replace(/[^\w\-. ()—]/g, '');
- const a = document.createElement('a');
- a.href = URL.createObjectURL(new Blob([md], { type:'text/markdown' }));
- a.download = fname;
- document.body.appendChild(a); a.click(); a.remove();
- }
- byId('btnDownloadMd').addEventListener('click', downloadMd);
- byId('btnDownloadMd2').addEventListener('click', downloadMd);
- /* ── Context panel buttons ───────────────────────────────────────────── */
- byId('btnPrettyCtx').addEventListener('click', () => {
- const ta = byId('ctx');
- try { ta.value = JSON.stringify(JSON.parse(ta.value), null, 2); } catch {}
- });
- byId('btnClearCtx').addEventListener('click', clearContext);
- /* ── Build TOC from markdown ─────────────────────────────────────────── */
- function buildTocFromMarkdown(md) {
- const lines = md.split(/\r?\n/);
- const items = [];
- for (const line of lines) {
- const m = /^(#{1,6})\s+(.*)$/.exec(line);
- if (m && m[1].length <= 3) items.push({ level: m[1].length, text: m[2].trim() });
- }
- if (!items.length) return '<p><em>No headings detected.</em></p>';
- return '<div>' + items.map(i => `<div style="margin-left:${(i.level-1)*16}px">${i.text}</div>`).join('') + '</div>';
- }
- /* ── HTML cover template for export ─────────────────────────────────── */
- function htmlCoverAndBody(ctx, htmlBody) {
- const address = ctx.address || '';
- const preparedFor = byId('preparedFor').value || ctx.prepared_for || '—';
- const preparedBy = byId('preparedBy').value || ctx.prepared_by || 'Modulos Design';
- const when = new Date().toLocaleDateString('en-AU', { day:'numeric', month:'long', year:'numeric' });
- const intent = ctx.project_intent ? `<p><strong>Project intent:</strong> ${ctx.project_intent}</p>` : '';
- return `<html><head><meta charset="utf-8">
- <style>
- body { font-family: Calibri, Arial, sans-serif; font-size: 11pt; color: #111; }
- h1,h2,h3 { color: #0f172a; }
- .cover { min-height: 200pt; padding: 40pt 0; }
- .cover h1 { font-size: 28pt; margin: 0 0 8pt 0; }
- .meta { margin-top: 12pt; line-height: 1.8; }
- .pb { page-break-after: always; }
- .toc h2 { margin-top: 0; }
- table { border-collapse: collapse; width: 100%; margin: 1em 0; }
- th { background: #f1f5f9; padding: 6pt 8pt; text-align: left; border: 1px solid #cbd5e1; }
- td { padding: 5pt 8pt; border: 1px solid #cbd5e1; vertical-align: top; }
- </style>
- </head><body>
- <div class="cover">
- <h1>Supporting Planning Report</h1>
- <div><strong>Address:</strong> ${address}</div>
- <div class="meta">
- <div>Prepared for: <strong>${preparedFor}</strong></div>
- <div>Prepared by: <strong>${preparedBy}</strong></div>
- <div>Date: <strong>${when}</strong></div>
- </div>
- ${intent}
- </div>
- <div class="pb"></div>
- <div class="toc">
- <h2>Contents</h2>
- <div id="toc-placeholder"><em>Generated on export</em></div>
- </div>
- <div class="pb"></div>
- ${htmlBody}
- </body></html>`;
- }
- /* ── DOCX export ─────────────────────────────────────────────────────── */
- function markdownToHtml(md) {
- marked.setOptions({ breaks: true, gfm: true });
- return marked.parse(md || '');
- }
- function downloadDocx() {
- const md = byId('combinedMd').value || '';
- if (!md) { setStatus('Assemble the report first.', 'error'); return; }
- let ctx = {};
- try { ctx = JSON.parse(byId('ctx').value || '{}'); } catch {}
- const htmlBody = markdownToHtml(md);
- const html = htmlCoverAndBody(ctx, htmlBody);
- const toc = buildTocFromMarkdown(md);
- const final = html.replace('<div id="toc-placeholder"><em>Generated on export</em></div>', toc);
- const blob = window.htmlDocx.asBlob(final);
- let addr = (ctx.address || 'Planning Report').replace(/[^\w\-. ()]/g, '');
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `${addr}.docx`;
- document.body.appendChild(a); a.click(); a.remove();
- }
- async function createServerDocx() {
- const md = byId('combinedMd').value || '';
- if (!md) { setStatus('Assemble first.', 'error'); return; }
- let ctx = {};
- try { ctx = JSON.parse(byId('ctx').value || '{}'); } catch {}
- const res = await fetch('generate_docx.php', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ markdown: md, context: ctx })
- });
- const out = await res.json().catch(() => null);
- if (!res.ok || !out?.ok) throw new Error(out?.error || `HTTP ${res.status}`);
- window.open(out.url, '_blank');
- }
- byId('btnDocx').addEventListener('click', async () => {
- const btn = byId('btnDocx');
- const orig = btn.innerHTML;
- btn.disabled = true;
- btn.innerHTML = '<span class="spinner"></span> Exporting…';
- try {
- await createServerDocx();
- setStatus('DOCX exported.', '');
- } catch {
- downloadDocx(); // client-side fallback
- } finally {
- btn.disabled = false;
- btn.innerHTML = orig;
- }
- });
- /* ── Google Doc ──────────────────────────────────────────────────────── */
- async function createGoogleDoc() {
- const md = byId('combinedMd').value || '';
- if (!md) { setStatus('Assemble first.', 'error'); return; }
- let ctx = {};
- try { ctx = JSON.parse(byId('ctx').value || '{}'); } catch {}
- const useTemplate = byId('useGdocTemplate').checked;
- const res = await fetch('create_gdoc.php', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ markdown: md, context: ctx, use_template: useTemplate })
- });
- const out = await res.json().catch(() => null);
- if (!res.ok || !out?.ok) throw new Error(out?.error || `HTTP ${res.status}`);
- window.open(out.url, '_blank');
- }
- byId('btnGdoc').addEventListener('click', async () => {
- const btn = byId('btnGdoc');
- const orig = btn.innerHTML;
- btn.disabled = true;
- btn.innerHTML = '<span class="spinner"></span> Creating…';
- try {
- await createGoogleDoc();
- setStatus('Google Doc created.', '');
- } catch(e) {
- setStatus('Google Doc failed: ' + e.message, 'error');
- } finally {
- btn.disabled = false;
- btn.innerHTML = orig;
- }
- });
- /* ── Boot: load context from sessionStorage ──────────────────────────── */
- document.addEventListener('DOMContentLoaded', () => {
- const loaded = loadContextFromStorage();
- if (!loaded) setStatus('No context loaded — paste JSON or open from Property Lookup.', '');
- });
- </script>
- </body>
- </html>
|