deploy ProspectOS workspace v5 final
This commit is contained in:
@@ -53,3 +53,6 @@ AI_PROVIDER=custom
|
||||
AI_BASE_URL=
|
||||
AI_API_KEY=
|
||||
AI_MODEL=
|
||||
# Maximum time to wait for AI-written mockup copy before using the reliable
|
||||
# premium fallback renderer. Keep this below your reverse-proxy timeout.
|
||||
AI_MOCKUP_TIMEOUT_SECONDS=45
|
||||
|
||||
@@ -39,6 +39,14 @@ a production site and should be checked and edited before sending.
|
||||
The design prompt receives a small category-matched library of Unsplash stock
|
||||
images; generated pages may use only those supplied image URLs, and the
|
||||
fallback template uses the same curated set.
|
||||
Each saved concept also has an **Edit template** action. It opens the shareable
|
||||
site beside a chat-style editor where you can ask for copy, emphasis, palette,
|
||||
imagery, or layout changes. The request is sent to the configured AI with the
|
||||
prospect's public evidence and rendered through the same deterministic,
|
||||
sanitised template renderer. Every successful edit creates a new version and
|
||||
share token; earlier versions remain in the prospect drawer and are never
|
||||
overwritten. If AI is unavailable, safe visual hints such as “make it darker”
|
||||
still select one of the built-in palettes.
|
||||
The **Logs** view separates API calls, user actions, AI requests, searches,
|
||||
Browser Use/tool steps, and errors. AI request payloads and responses are
|
||||
truncated and credentials such as API keys, authorization headers, tokens, and
|
||||
@@ -111,6 +119,7 @@ error rather than remaining stuck forever.
|
||||
| `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint |
|
||||
| `AI_API_KEY` | empty | Optional provider key; keep server-side |
|
||||
| `AI_MODEL` | empty | Model name used for evidence-grounded drafts |
|
||||
| `AI_MOCKUP_TIMEOUT_SECONDS` | `45` | Maximum AI wait for mockup copy before the deterministic premium renderer is used (20–300 seconds) |
|
||||
|
||||
### Nous Research Portal
|
||||
|
||||
@@ -171,10 +180,11 @@ The API equivalents are `POST /api/prospects/{id}/website-candidates`,
|
||||
Open **Settings → Browser and discovery timing** to change Browser Use's
|
||||
enabled state, total investigation timeout, maximum agent steps, and per-call
|
||||
AI timeout. The same panel controls the Maps worker wait timeout/poll interval,
|
||||
search depth, and website health/email scan timeout. Values are stored in the
|
||||
local SQLite database, survive container restarts, and override `.env` defaults
|
||||
for new runs. The safe UI ranges prevent a malformed value from creating an
|
||||
unbounded worker.
|
||||
search depth, website health/email scan timeout, and the maximum wait for
|
||||
AI-written mockup copy. Values are stored in the local SQLite database, survive
|
||||
container restarts, and override `.env` defaults for new runs. The safe UI
|
||||
ranges prevent a malformed value from creating an unbounded worker; a slow
|
||||
mockup provider automatically falls back to the complete local renderer.
|
||||
|
||||
The app stores its catalogue in the `prospect_data` Docker volume. The worker
|
||||
stores its own job data in `gmaps_data`; keep both volumes if you want to retain
|
||||
@@ -228,9 +238,22 @@ with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and
|
||||
is sent as `promotion_id` and retained on that prospect's draft.
|
||||
Mockups are created with `POST /api/prospects/{id}/mockup`, listed with
|
||||
`GET /api/prospects/{id}/mockups`, and opened by their public
|
||||
`/mockups/{token}` share URL. Set `PUBLIC_BASE_URL` when the reverse proxy does
|
||||
not forward the public host; otherwise the link is built from the request's
|
||||
forwarded host or `Host` header.
|
||||
`/mockups/{token}` share URL. Choose `mode=onepage` for a single scrollable
|
||||
landing page or `mode=multipage` for real Home, Services, About, and Contact
|
||||
routes. Both modes use the same deterministic premium renderer: responsive
|
||||
sticky navigation, layered hero imagery, editorial service cards, accessible
|
||||
contact calls-to-action, a dark structured footer, hover states, and CSS-only
|
||||
entrance/ambient animations with reduced-motion support. AI is asked only for
|
||||
bounded copy and curated image choices; malformed, truncated, unavailable, or
|
||||
slow AI responses fall back to the same complete renderer instead of failing
|
||||
the request. Set `PUBLIC_BASE_URL` when the reverse proxy does not forward the
|
||||
public host; otherwise the link is built from the request's forwarded host or
|
||||
`Host` header.
|
||||
Use `GET /api/mockups/{id}` to load editor metadata and
|
||||
`POST /api/mockups/{id}/edit` with `{"instruction":"...","page":"all"}` to
|
||||
create the next version. The edit endpoint requires reviewer access, limits
|
||||
instructions to 3,000 characters, and accepts `all`, `home`, `services`,
|
||||
`about`, or `contact` as the page scope.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
+413
-7
File diff suppressed because one or more lines are too long
@@ -46,6 +46,7 @@ services:
|
||||
AI_BASE_URL: ${AI_BASE_URL:-}
|
||||
AI_API_KEY: ${AI_API_KEY:-}
|
||||
AI_MODEL: ${AI_MODEL:-}
|
||||
AI_MOCKUP_TIMEOUT_SECONDS: ${AI_MOCKUP_TIMEOUT_SECONDS:-45}
|
||||
volumes:
|
||||
- prospect_data:/data
|
||||
depends_on:
|
||||
|
||||
+89
-1
@@ -201,6 +201,92 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
self.assertIn("Services", body)
|
||||
self.assertIn("images.unsplash.com", body)
|
||||
self.assertIn("https://images.unsplash.com", response.getheader("Content-Security-Policy"))
|
||||
self.assertIn("frame-ancestors 'none'", response.getheader("Content-Security-Policy"))
|
||||
|
||||
def test_mockup_editor_creates_a_new_version_and_keeps_original(self):
|
||||
conn = server.db()
|
||||
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Brightspark Electrical", "maps:bright-editor", "electrician", server.now()))
|
||||
conn.commit(); conn.close()
|
||||
status, original = self.request("POST", "/api/prospects/1/mockup", {"mode": "multipage"})
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(original.get("version"), None) # v1 rows remain backwards-compatible
|
||||
conn = server.db()
|
||||
for key, value in (("AI_PROVIDER", "nous"), ("AI_MODEL", "meituan/longcat-2.0:free"), ("AI_API_KEY", "test-key")):
|
||||
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now()))
|
||||
conn.commit(); conn.close()
|
||||
ai_response = {"choices": [{"message": {"content": json.dumps({
|
||||
"tagline": "Powerful work. Presented with confidence.",
|
||||
"intro": "A sharper digital home for Brightspark Electrical.",
|
||||
"services": [{"name": "Electrical services", "description": "A clear way to understand the work available."}],
|
||||
"about": "A refined concept based on the public business listing.",
|
||||
"cta": "Let’s make the next step easy.",
|
||||
"hero_image": server.STOCK_IMAGE_LIBRARY["electric"][0],
|
||||
"gallery_image": server.STOCK_IMAGE_LIBRARY["electric"][1],
|
||||
})}}]}
|
||||
with patch("api.server.http_json", return_value=ai_response) as request:
|
||||
status, edited = self.request("POST", f"/api/mockups/{original['id']}/edit", {"instruction": "Make the whole site darker and put more emphasis on services.", "page": "all"})
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(edited["version"], 2)
|
||||
self.assertEqual(edited["parent_id"], original["id"])
|
||||
self.assertEqual(edited["mode"], "multipage")
|
||||
self.assertIn("Applied your request", edited["change_summary"])
|
||||
self.assertIn("max_tokens", request.call_args.args[2])
|
||||
self.assertEqual(request.call_args.args[2]["max_tokens"], 1800)
|
||||
self.conn.request("GET", "/mockups/" + edited["token"] + "/services?embed=1")
|
||||
response = self.conn.getresponse(); body = response.read().decode("utf-8")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn("frame-ancestors 'self'", response.getheader("Content-Security-Policy"))
|
||||
self.assertIn("--paper:#091321", body)
|
||||
self.assertIn("Electrical services", body)
|
||||
self.conn.request("GET", "/mockups/" + edited["token"])
|
||||
response = self.conn.getresponse(); edited_home = response.read().decode("utf-8")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn("Powerful work", edited_home)
|
||||
self.conn.request("GET", "/mockups/" + original["token"] + "/services")
|
||||
response = self.conn.getresponse(); original_body = response.read().decode("utf-8")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn("Brightspark Electrical", original_body)
|
||||
status, metadata = self.request("GET", f"/api/mockups/{edited['id']}")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(metadata["version"], 2)
|
||||
status, listed = self.request("GET", "/api/prospects/1/mockups")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual([item["version"] for item in listed["items"]], [2, 1])
|
||||
|
||||
def test_multipage_ai_uses_compact_content_and_renders_all_pages(self):
|
||||
conn = server.db()
|
||||
conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Brightspark Electrical", "maps:bright-ai", "electrician", server.now()))
|
||||
for key, value in (("AI_PROVIDER", "nous"), ("AI_MODEL", "meituan/longcat-2.0:free"), ("AI_API_KEY", "test-key")):
|
||||
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now()))
|
||||
conn.commit(); conn.close()
|
||||
response = {"choices": [{"message": {"content": json.dumps({
|
||||
"tagline": "Powerful work. Presented with clarity.",
|
||||
"intro": "A considered digital home for Brightspark Electrical.",
|
||||
"services": [{"name": "Electrical services", "description": "A clear way to understand the work available."}],
|
||||
"about": "A refined concept grounded in the public business listing.",
|
||||
"cta": "Let’s make the next step easy.",
|
||||
"hero_image": server.STOCK_IMAGE_LIBRARY["electric"][0],
|
||||
"gallery_image": server.STOCK_IMAGE_LIBRARY["electric"][1],
|
||||
})}}]}
|
||||
with patch("api.server.http_json", return_value=response) as request:
|
||||
status, created = self.request("POST", "/api/prospects/1/mockup", {"mode": "multipage"})
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(created["generation_source"], "ai")
|
||||
self.assertEqual({item["slug"] for item in created["pages"]}, {"home", "services", "about", "contact"})
|
||||
payload = request.call_args.args[2]
|
||||
self.assertEqual(payload["max_tokens"], 1800)
|
||||
self.assertNotIn("pages", json.dumps(payload["messages"]))
|
||||
stored = server.db().execute("SELECT pages_json FROM mockups WHERE id=?", (created["id"],)).fetchone()
|
||||
pages = json.loads(stored[0])
|
||||
self.assertIn("Powerful work", pages["home"])
|
||||
self.assertTrue(all("Brightspark Electrical" in html and "@keyframes" in html and "<footer" in html for html in pages.values()))
|
||||
|
||||
def test_onepage_mockup_uses_in_page_navigation(self):
|
||||
html = server.premium_render_pages({"name": "Acme", "category": "plumber"}, mode="onepage")["home"]
|
||||
self.assertIn('href="#services"', html)
|
||||
self.assertIn('id="services"', html)
|
||||
self.assertIn('id="about"', html)
|
||||
self.assertIn('id="contact"', html)
|
||||
|
||||
def test_browser_investigator_normalises_and_filters_candidates(self):
|
||||
raw = '{"candidates":[{"url":"https://www.acme.co.za/about","confidence":88,"evidence":["name match"]},{"url":"https://facebook.com/acme","confidence":99},{"domain":"other.co.za","confidence":"42","evidence":"not a list"}]}'
|
||||
@@ -314,18 +400,20 @@ class BrowserOnlyAppTests(unittest.TestCase):
|
||||
status, body = self.request("POST", "/api/settings", {
|
||||
"browser_use": {"enabled": True, "max_steps": 22, "timeout_seconds": 900, "llm_timeout_seconds": 120},
|
||||
"maps": {"request_timeout_seconds": 1800, "poll_seconds": 4, "max_depth": 3},
|
||||
"limits": {"website_scan_timeout_seconds": 15},
|
||||
"limits": {"website_scan_timeout_seconds": 15, "ai_mockup_timeout_seconds": 75},
|
||||
})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body["browser_use"]["timeout_seconds"], 900)
|
||||
self.assertEqual(body["maps"]["request_timeout_seconds"], 1800)
|
||||
self.assertEqual(body["limits"]["website_scan_timeout_seconds"], 15)
|
||||
self.assertEqual(body["limits"]["ai_mockup_timeout_seconds"], 75)
|
||||
status, settings = self.request("GET", "/api/settings")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(settings["browser_use"]["max_steps"], 22)
|
||||
self.assertEqual(settings["browser_use"]["llm_timeout_seconds"], 120)
|
||||
self.assertEqual(settings["maps"]["poll_seconds"], 4.0)
|
||||
self.assertEqual(settings["maps"]["max_depth"], 3)
|
||||
self.assertEqual(settings["limits"]["ai_mockup_timeout_seconds"], 75)
|
||||
self.assertEqual(server.browser_use_limits(), (22, 900))
|
||||
self.assertEqual(server.website_scan_timeout_seconds(), 15)
|
||||
|
||||
|
||||
+97
@@ -150,3 +150,100 @@ function addMockupPanel(id){const body=$('drawerBody');const p=prospects.find(x=
|
||||
async function generateMockupPreview(id,button,mode='onepage'){const status=$('mockupStatus');const chooser=document.querySelector('.mockup-mode-chooser');if(button)button.disabled=true;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=true);if(status){status.textContent=`Generating ${mode==='multipage'?'multi-page':'one-page'} preview… The current public website may take a moment to read.`;status.className='notice'}try{const data=await api(`/api/prospects/${id}/mockup`,{method:'POST',body:JSON.stringify({instructions:'',mode})});if(status){status.innerHTML=`Preview ready (${esc(data.mode==='multipage'?'multiple pages':'one page')}). <a href="${esc(data.share_url)}" target="_blank" rel="noopener">Open share link ↗</a>`;status.className='notice ok'}const list=$('mockupsList');if(list){const latest=await api(`/api/prospects/${id}/mockups`);list.innerHTML=renderMockupItems(latest.items||[])}return data}catch(error){if(status){status.textContent=error.message;status.className='notice error'}throw error}finally{if(button)button.disabled=false;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=false)}}
|
||||
async function loadMessageMockupOption(id){const context=$('messageContext');if(!context)return;context.querySelector('.mockup-option')?.remove();try{const data=await api(`/api/prospects/${id}/mockups`);const latest=(data.items||[])[0];if(!latest)return;const option=document.createElement('label');option.className='mockup-option';option.innerHTML=`<input type="checkbox" id="includeMockup" data-mockup-id="${esc(latest.id)}" checked><span><b>Include latest concept preview</b><small>Share the editable preview link once in the email. <a href="${esc(latest.share_url)}" target="_blank" rel="noopener">Open it ↗</a></small></span>`;context.append(option)}catch(error){logAction('mockup_option_load_failed',{prospect_id:id,error:error.message},'warn')}}
|
||||
document.addEventListener('click',async e=>{const generate=e.target.closest('[data-generate-mockup]');if(generate){e.preventDefault();try{await generateMockupPreview(generate.dataset.generateMockup,generate,generate.dataset.mockupMode||'onepage')}catch{}return}const copy=e.target.closest('[data-copy-mockup]');if(copy){e.preventDefault();try{await navigator.clipboard.writeText(copy.dataset.copyMockup);copy.textContent='Copied';setTimeout(()=>{copy.textContent='Copy link'},1400)}catch{prompt('Copy preview link',copy.dataset.copyMockup)}}});
|
||||
|
||||
/* Keep the mockup generation timeout visible with the other runtime controls. */
|
||||
const _renderSettingsWithMockupTimeout=renderSettings;
|
||||
renderSettings=function(){
|
||||
_renderSettingsWithMockupTimeout();
|
||||
const websiteTimeout=$('websiteTimeout');
|
||||
const group=websiteTimeout?.closest('.setting-group');
|
||||
if(!group||$('aiMockupTimeout'))return;
|
||||
const label=document.createElement('label');
|
||||
label.innerHTML=`AI mockup copy timeout (seconds)<input id="aiMockupTimeout" type="number" min="20" max="300" step="5" value="${Number(settings.limits?.ai_mockup_timeout_seconds||45)}"><small>After this wait the complete premium fallback renders automatically.</small>`;
|
||||
group.append(label);
|
||||
};
|
||||
/* Capture this form before the legacy handler so the new value is saved in the same request. */
|
||||
document.addEventListener('submit',async e=>{
|
||||
if(e.target?.id!=='runtimeSettingsForm')return;
|
||||
e.preventDefault();e.stopImmediatePropagation();
|
||||
const message=$('runtimeSettingsMessage');
|
||||
if(message){message.textContent='Saving…';message.className='notice'}
|
||||
try{
|
||||
const result=await api('/api/settings',{method:'POST',body:JSON.stringify({
|
||||
browser_use:{enabled:$('browserEnabled').checked,max_steps:Number($('browserSteps').value),timeout_seconds:Number($('browserTimeout').value),llm_timeout_seconds:Number($('browserLlmTimeout').value)},
|
||||
maps:{request_timeout_seconds:Number($('mapsTimeout').value),poll_seconds:Number($('mapsPoll').value),max_depth:Number($('mapsDepth').value)},
|
||||
limits:{website_scan_timeout_seconds:Number($('websiteTimeout').value),ai_mockup_timeout_seconds:Number($('aiMockupTimeout')?.value||45)}
|
||||
})});
|
||||
settings.browser_use=result.browser_use||settings.browser_use;settings.maps={...settings.maps,...(result.maps||{})};settings.limits={...settings.limits,...(result.limits||{})};
|
||||
if(message){message.textContent='Runtime settings saved.';message.className='notice ok'}
|
||||
}catch(error){if(message){message.textContent=error.message;message.className='notice error'}}
|
||||
},true);
|
||||
|
||||
/* AI template editor ----------------------------------------------------- */
|
||||
/*
|
||||
* Keep the preview in an iframe and the conversation in the parent app.
|
||||
* The public preview itself is static and CSP/sandboxed, so an AI request
|
||||
* can never inject a chat script, form handler, or tracking pixel into the
|
||||
* share link.
|
||||
*/
|
||||
renderMockupItems=function(items=[]){
|
||||
if(!items.length)return '<p class="muted">No preview yet. Generate one from the public facts already collected for this prospect.</p>';
|
||||
return `<div class="mockup-list">${items.map(item=>`<div class="mockup-card"><div class="mockup-card-main"><b>${esc(item.title||'Website concept preview')}</b><small>${esc(item.mode==='multipage'?'Multi-page site':'One-page site')} · ${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · version ${Number(item.version||1)} · ${date(item.created_at)}</small>${item.pages?.length>1?`<span class="muted">${item.pages.length} pages · ${item.pages.map(page=>`<a href="${esc(page.url)}" target="_blank" rel="noopener">${esc(page.slug)}</a>`).join(' · ')}</span>`:''}${item.source_url?`<span class="muted">Based on ${esc(item.source_url)}</span>`:''}${Number(item.version||1)>1?`<span class="muted">Edited from an earlier version${item.edit_instructions?`: ${esc(item.edit_instructions)}`:''}</span>`:''}</div><div class="mockup-card-actions"><button type="button" class="primary" data-edit-mockup="${esc(item.id)}" data-edit-prospect="${esc(item.prospect_id)}" data-edit-share="${esc(item.share_url)}" data-edit-mode="${esc(item.mode||'onepage')}" data-edit-version="${esc(item.version||1)}">Edit template</button><a class="secondary mockup-link" href="${esc(item.share_url)}" target="_blank" rel="noopener">Open preview ↗</a><button type="button" class="secondary" data-copy-mockup="${esc(item.share_url)}">Copy link</button></div></div>`).join('')}</div>`;
|
||||
};
|
||||
|
||||
function editorPageOptions(mode){
|
||||
return mode==='multipage'?'<option value="all">Entire site</option><option value="home">Home page</option><option value="services">Services page</option><option value="about">About page</option><option value="contact">Contact page</option>':'<option value="all">Entire page</option>';
|
||||
}
|
||||
function editorPreviewUrl(url,refresh=''){
|
||||
const value=String(url||'');if(!value)return '';
|
||||
const join=value.includes('?')?'&':'?';return `${value}${join}embed=1${refresh?`&refresh=${encodeURIComponent(refresh)}`:''}`;
|
||||
}
|
||||
|
||||
function editorAddMessage(messages,kind,text){
|
||||
if(!messages)return;
|
||||
const bubble=document.createElement('div');bubble.className=`mockup-chat-message ${kind||'assistant'}`;bubble.textContent=String(text||'');messages.append(bubble);messages.scrollTop=messages.scrollHeight;
|
||||
}
|
||||
|
||||
async function openMockupEditor(button){
|
||||
const id=button?.dataset?.editMockup;
|
||||
if(!id)return;
|
||||
document.getElementById('mockupEditorDialog')?.remove();
|
||||
let state={id:Number(id),prospect_id:Number(button.dataset.editProspect||0),share_url:button.dataset.editShare||'',mode:button.dataset.editMode||'onepage',version:Number(button.dataset.editVersion||1),title:'Website concept preview'};
|
||||
const dialog=document.createElement('dialog');dialog.id='mockupEditorDialog';dialog.className='mockup-editor-dialog';
|
||||
dialog.innerHTML=`<div class="mockup-editor-shell"><header class="mockup-editor-head"><div><span class="eyebrow">TEMPLATE EDITOR</span><h2>${esc(state.title)}</h2><p>Ask the AI for a focused change, then review the new version before sharing it.</p></div><div class="mockup-editor-head-actions"><span id="mockupEditorVersion" class="pill">Version ${state.version}</span><button type="button" class="icon-btn" data-editor-close aria-label="Close editor">×</button></div></header><div class="mockup-editor-layout"><section class="mockup-editor-preview"><div class="mockup-editor-preview-bar"><b>Live concept preview</b><a id="mockupEditorOpen" href="${esc(state.share_url)}" target="_blank" rel="noopener">Open full preview ↗</a></div><iframe id="mockupEditorFrame" title="Website concept preview" sandbox="allow-same-origin" referrerpolicy="no-referrer" src="${esc(editorPreviewUrl(state.share_url))}"></iframe></section><aside class="mockup-editor-chat"><div class="mockup-chat-head"><div><span class="eyebrow">AI DESIGN CHAT</span><h3>What should change?</h3></div><span class="mockup-chat-badge">Safe renderer</span></div><div id="mockupEditorMessages" class="mockup-chat-messages"></div><form id="mockupEditorForm" class="mockup-chat-form"><label>Apply to<select id="mockupEditorPage">${editorPageOptions(state.mode)}</select></label><textarea id="mockupEditorInput" rows="5" required maxlength="3000" placeholder="e.g. Make the design darker, make Services more prominent, and change the headline to something more confident."></textarea><div id="mockupEditorStatus" class="notice">The AI uses the prospect evidence and your instruction. It will not add scripts, forms, tracking, or unsupported claims.</div><button class="primary" type="submit" id="mockupEditorApply">Apply change <span>→</span></button></form><div class="mockup-editor-note"><b>Version history is preserved</b><span>Every applied change creates a new share link. The previous version remains available in the prospect drawer.</span></div></aside></div></div>`;
|
||||
document.body.append(dialog);
|
||||
const close=()=>{try{dialog.close()}catch{}dialog.remove()};
|
||||
dialog.querySelector('[data-editor-close]')?.addEventListener('click',close);
|
||||
dialog.addEventListener('cancel',e=>{e.preventDefault();close()});
|
||||
dialog.addEventListener('click',e=>{if(e.target===dialog)close()});
|
||||
try{dialog.showModal()}catch{dialog.setAttribute('open','')}
|
||||
const messages=$('mockupEditorMessages'),frame=$('mockupEditorFrame'),openLink=$('mockupEditorOpen'),versionLabel=$('mockupEditorVersion'),form=$('mockupEditorForm'),input=$('mockupEditorInput'),status=$('mockupEditorStatus'),apply=$('mockupEditorApply');
|
||||
editorAddMessage(messages,'assistant','I can refine the layout, copy, palette, emphasis, and imagery while keeping the concept grounded in the public prospect evidence. What would you like changed?');
|
||||
try{
|
||||
const remote=await api(`/api/mockups/${state.id}`);state={...state,...remote};
|
||||
const editorTitle=dialog.querySelector('.mockup-editor-head h2');if(editorTitle)editorTitle.textContent=state.title||'Website concept preview';
|
||||
if(frame&&state.share_url)frame.src=editorPreviewUrl(state.share_url);if(openLink)openLink.href=state.share_url||'#';if(versionLabel)versionLabel.textContent=`Version ${Number(state.version||1)}`;
|
||||
}catch(error){editorAddMessage(messages,'system',`Could not load editor metadata: ${error.message}. The preview can still be edited.`)}
|
||||
form?.addEventListener('submit',async event=>{
|
||||
event.preventDefault();
|
||||
const instruction=String(input?.value||'').trim();if(instruction.length<3){if(status){status.textContent='Tell the AI what you would like changed.';status.className='notice error'}return}
|
||||
const page=$('mockupEditorPage')?.value||'all';
|
||||
if(apply)apply.disabled=true;if(input)input.disabled=true;if(status){status.textContent='Applying your request and rendering a new version…';status.className='notice'}
|
||||
editorAddMessage(messages,'user',instruction);
|
||||
try{
|
||||
const result=await api(`/api/mockups/${state.id}/edit`,{method:'POST',body:JSON.stringify({instruction,page})});
|
||||
state={...state,...result};const editorTitle=dialog.querySelector('.mockup-editor-head h2');if(editorTitle)editorTitle.textContent=state.title||'Website concept preview';editorAddMessage(messages,'assistant',`${result.change_summary||'Your change was applied.'} Version ${Number(result.version||1)} is ready to review.`);
|
||||
if(frame&&result.share_url)frame.src=editorPreviewUrl(result.share_url,Date.now());if(openLink)openLink.href=result.share_url||'#';if(versionLabel)versionLabel.textContent=`Version ${Number(result.version||1)}`;if(input)input.value='';if(status){status.textContent='New version ready. Review the preview, then ask for another change or copy the share link.';status.className='notice ok'}
|
||||
if(state.prospect_id){try{const latest=await api(`/api/prospects/${state.prospect_id}/mockups`);const list=$('mockupsList');if(list)list.innerHTML=renderMockupItems(latest.items||[])}catch{}}
|
||||
}catch(error){editorAddMessage(messages,'system',`I could not apply that change: ${error.message}`);if(status){status.textContent=error.message;status.className='notice error'}}finally{if(apply)apply.disabled=false;if(input)input.disabled=false;input?.focus()}
|
||||
});
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
/* Capture before row-level click handlers so opening the editor does not
|
||||
also open/close the prospect drawer underneath it. */
|
||||
document.addEventListener('click',e=>{
|
||||
const edit=e.target.closest('[data-edit-mockup]');
|
||||
if(!edit)return;
|
||||
e.preventDefault();e.stopPropagation();openMockupEditor(edit);
|
||||
},true);
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v2"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v2"></script></body></html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0d1726"><title>ProspectOS</title><link rel="stylesheet" href="/style.css?v=workspace-v5"></head>
|
||||
<body><div class="app-shell"><aside class="sidebar"><div class="brand"><span class="brand-mark">P</span><div><b>ProspectOS</b><small>Growth workspace</small></div></div><nav id="nav"><a href="#dashboard" data-route="dashboard"><span>⌂</span>Dashboard</a><a href="#discover" data-route="discover"><span>⌁</span>Discover</a><a href="#prospects" data-route="prospects"><span>▦</span>Prospects</a><a href="#runs" data-route="runs"><span>◷</span>Runs</a><a href="#settings" data-route="settings"><span>⚙</span>Settings</a></nav><div class="sidebar-foot"><span class="status-dot" id="sourceDot"></span><div><b id="sourceLabel">Checking worker</b><small id="sourceSub">Connecting…</small></div></div></aside><main class="main"><header class="topbar"><div><span class="crumb">Workspace</span><span class="slash">/</span><b id="pageName">Dashboard</b></div><div class="top-actions"><span id="updated">—</span><button class="icon-btn" id="refreshAll" title="Refresh">↻</button></div></header><div id="view"></div><footer>ProspectOS · public data only · manual review before contact</footer></main></div><aside id="detailDrawer" class="detail-drawer" aria-hidden="true"><div class="drawer-head"><div><span class="eyebrow">PROSPECT DETAIL</span><h2 id="drawerName">Prospect</h2></div><button class="icon-btn" id="closeDrawer">×</button></div><div id="drawerBody"></div></aside><dialog id="messageDialog"><form method="dialog" id="messageForm"><div class="dialog-head"><div><label class="eyebrow">AI ASSISTED DRAFT</label><h2 id="messageTitle">Tailor a message</h2><p>Use the evidence and your offer to create an editable draft.</p></div><button class="icon-btn" value="cancel">×</button></div><label for="offerInput">Your offer or instruction</label><textarea id="offerInput" rows="4" placeholder="Move hosting to Gold Plus for R119/month and rebuild the website free."></textarea><div id="messageStatus" class="notice"></div><button class="primary" id="generateMessage" type="button">Generate subject and email</button><label for="subjectOutput">Subject</label><input id="subjectOutput"><label for="bodyOutput">Email draft</label><textarea id="bodyOutput" rows="12"></textarea><div class="dialog-actions"><button class="secondary" id="copyMessage" type="button">Copy draft</button><button class="secondary" value="cancel">Close</button></div></form></dialog><script src="/app.js?v=workspace-v5"></script></body></html>
|
||||
|
||||
@@ -158,3 +158,17 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color:
|
||||
@media(max-width:680px){.mockup-card{display:block}.mockup-card-actions{margin-top:9px;justify-content:flex-start}.mockup-card-actions a,.mockup-card-actions button{flex:1}.mockup-panel .primary{width:100%}}
|
||||
.mockup-mode-chooser{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:12px 0}.mockup-mode-chooser button{display:grid;justify-items:start;gap:3px;text-align:left;padding:11px 12px}.mockup-mode-chooser button b{font-size:11px}.mockup-mode-chooser button small{font-size:10px;font-weight:500;opacity:.8}.mockup-mode-chooser .secondary{color:#48556c}
|
||||
@media(max-width:560px){.mockup-mode-chooser{grid-template-columns:1fr}.mockup-mode-chooser button{width:100%}}
|
||||
|
||||
/* AI template editor */
|
||||
.mockup-editor-dialog{width:min(1240px,calc(100vw - 28px));height:min(860px,calc(100vh - 28px));max-width:none;max-height:none;padding:0;border:0;border-radius:18px;background:#f8f9fd;color:#27344b;box-shadow:0 30px 100px #18233d45;overflow:hidden}
|
||||
.mockup-editor-dialog::backdrop{background:#111a2dcc;backdrop-filter:blur(5px)}
|
||||
.mockup-editor-shell{display:grid;grid-template-rows:auto minmax(0,1fr);height:100%;min-height:0}
|
||||
.mockup-editor-head{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:20px 24px;border-bottom:1px solid #e0e5ef;background:#fff}
|
||||
.mockup-editor-head h2{margin:5px 0 4px;font-size:20px;letter-spacing:-.035em}.mockup-editor-head p{margin:0;color:#7b879a;font-size:11px}.mockup-editor-head-actions{display:flex;align-items:center;gap:10px}.mockup-editor-head-actions .icon-btn{flex:0 0 auto}
|
||||
.mockup-editor-layout{display:grid;grid-template-columns:minmax(0,1fr) 370px;min-height:0}
|
||||
.mockup-editor-preview{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;background:#e9edf5}.mockup-editor-preview-bar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:11px 17px;border-bottom:1px solid #dce2ed;background:#f7f8fc;color:#5a687d;font-size:11px}.mockup-editor-preview-bar a{color:#5b50d3;text-decoration:none;font-weight:800;white-space:nowrap}.mockup-editor-preview iframe{display:block;width:100%;height:100%;min-height:360px;border:0;background:#fff}
|
||||
.mockup-editor-chat{display:grid;grid-template-rows:auto minmax(0,1fr) auto auto;min-height:0;border-left:1px solid #dfe4ed;background:#fff}.mockup-chat-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:19px 19px 13px;border-bottom:1px solid #edf0f5}.mockup-chat-head h3{margin:4px 0 0;font-size:16px;letter-spacing:-.025em}.mockup-chat-badge{padding:5px 8px;border-radius:999px;background:#eef8f5;color:#17876b;font-size:9px;font-weight:900;white-space:nowrap}
|
||||
.mockup-chat-messages{display:grid;align-content:start;gap:10px;overflow:auto;padding:16px 16px 10px;background:linear-gradient(180deg,#fbfcff,#fff)}.mockup-chat-message{max-width:92%;padding:10px 11px;border-radius:11px;font-size:11px;line-height:1.5;white-space:pre-wrap}.mockup-chat-message.assistant{justify-self:start;border:1px solid #e1e5ef;background:#f5f6fb;color:#59677e}.mockup-chat-message.user{justify-self:end;background:#635bff;color:#fff;box-shadow:0 8px 18px #635bff2b}.mockup-chat-message.system{justify-self:start;border:1px solid #f1c7cd;background:#fff5f6;color:#a33e4d}
|
||||
.mockup-chat-form{display:grid;gap:8px;padding:14px 16px 12px;border-top:1px solid #edf0f5}.mockup-chat-form label{display:grid;gap:5px;color:#627087;font-size:10px;font-weight:850}.mockup-chat-form select{margin:0;height:34px;font-size:11px}.mockup-chat-form textarea{margin:0;min-height:106px;resize:vertical;font-size:12px;line-height:1.5}.mockup-chat-form .notice{margin:0;font-size:10px;line-height:1.4}.mockup-chat-form button{width:100%;min-height:42px}.mockup-editor-note{display:grid;gap:4px;padding:12px 16px 16px;background:#fafbff;color:#8b96a8;font-size:10px;line-height:1.45}.mockup-editor-note b{color:#5c6a80;font-size:10px}.mockup-editor-note span{display:block}
|
||||
@media(max-width:900px){.mockup-editor-dialog{width:min(760px,calc(100vw - 20px));height:min(900px,calc(100vh - 20px))}.mockup-editor-layout{grid-template-columns:1fr;grid-template-rows:minmax(310px,1fr) auto}.mockup-editor-preview iframe{min-height:300px}.mockup-editor-chat{grid-template-rows:auto minmax(95px,1fr) auto auto;border-left:0;border-top:1px solid #dfe4ed;max-height:430px}.mockup-chat-messages{min-height:95px;max-height:160px}}
|
||||
@media(max-width:560px){.mockup-editor-dialog{width:100vw;height:100vh;border-radius:0}.mockup-editor-head{padding:15px 16px}.mockup-editor-head h2{font-size:17px}.mockup-editor-head p{max-width:230px}.mockup-editor-head-actions .pill{display:none}.mockup-editor-layout{grid-template-rows:minmax(260px,1fr) auto}.mockup-editor-preview-bar{padding:9px 12px}.mockup-editor-chat{max-height:430px}.mockup-chat-form{padding:11px 12px}.mockup-editor-note{padding:10px 12px 13px}}
|
||||
|
||||
Reference in New Issue
Block a user