standalone

This commit is contained in:
2026-05-28 01:25:53 +08:00
parent ca9cc62b8c
commit 16847a78ca
12 changed files with 859 additions and 443 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -65,7 +65,6 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
function getHtml(version: string, betaToolsEnabled: boolean): string { function getHtml(version: string, betaToolsEnabled: boolean): string {
const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta)); const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta));
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length;
const toolButtons = visibleTools.map(renderToolButton).join('\n'); const toolButtons = visibleTools.map(renderToolButton).join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
@@ -140,38 +139,35 @@ function getHtml(version: string, betaToolsEnabled: boolean): string {
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
} }
.beta-toggle { .footer {
margin-top: 12px; margin-top: auto;
padding-top: 8px; padding-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: 8px;
border-top: 1px solid var(--vscode-panel-border); border-top: 1px solid var(--vscode-panel-border);
} }
.beta-toggle button { .beta-toggle {
width: 100%; padding: 0;
padding: 7px 9px;
background: transparent; background: transparent;
color: var(--vscode-foreground); color: var(--vscode-descriptionForeground);
border: 1px solid var(--vscode-panel-border); border: 0;
border-radius: 5px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
font: inherit; font: inherit;
}
.beta-toggle button:hover {
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
}
.beta-note {
display: block;
margin-top: 3px;
color: var(--vscode-descriptionForeground);
font-size: 10px; font-size: 10px;
line-height: 1.3; opacity: 0.72;
} }
.footer { .beta-toggle:hover {
margin-top: 8px; opacity: 1;
color: var(--vscode-descriptionForeground); text-decoration: underline;
font-size: 8px; }
text-align: right; .footer-version {
opacity: 0.75; opacity: 0.75;
text-align: right;
} }
</style> </style>
</head> </head>
@@ -179,13 +175,10 @@ function getHtml(version: string, betaToolsEnabled: boolean): string {
<div class="tools"> <div class="tools">
${toolButtons} ${toolButtons}
</div> </div>
<div class="beta-toggle"> <div class="footer">
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}"> <button id="betaToggle" class="beta-toggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'} <span class="footer-version">${version} - JJGC 00784</span>
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span>
</button>
</div> </div>
<div class="footer">${version} - JJGC 00784</div>
<script> <script>
const vscode = acquireVsCodeApi(); const vscode = acquireVsCodeApi();
document.querySelectorAll('.tool-btn').forEach((btn) => { document.querySelectorAll('.tool-btn').forEach((btn) => {

View File

@@ -35,31 +35,50 @@ func pickApplovinUserAgent(randomize bool) string {
func ApplovinPage(w http.ResponseWriter, r *http.Request) { func ApplovinPage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>AppLovin Playable Preview (QR)</h2> <header class="tool-header">
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p> <h2 class="tool-title">AppLovin Playable Preview</h2>
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
</header>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;"> <section class="tool-panel">
<button id="pick" class="secondary">Add files...</button> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<button id="clear" class="secondary" style="display:none;">Clear</button> <div class="panel-body">
<span id="fileName" class="hint">(no files)</span> <div class="control-group">
</div> <div class="file-row">
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div> <button id="pickFolder" class="secondary">Select Folder</button>
<div style="display:flex;gap:8px;"> <button id="pick" class="secondary">Select File(s)</button>
<button id="upload">Upload to AppLovin</button> <button id="clear" class="secondary">Clear</button>
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button> </div>
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button> <div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
</div> <div id="fileList" class="selected-files"></div>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<button id="saveAll" class="secondary" disabled>Save All</button>
<button id="regenAll" class="secondary" disabled>Regenerate</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="status" style="margin-top:12px;min-height:20px;"></div> <div id="results" class="results-panel"></div>
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
<style> <style>
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; } .qr-cell { text-align:center; }
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; } .qr-cell img { width:104px; height:104px; max-width:100%; padding:6px; border-radius:4px; background:#fff; cursor:zoom-in; }
.result-actions { display:flex; gap:6px; flex-wrap:wrap; }
.qr-results:not(.has-errors) .error-col { display:none; }
.qr-modal { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:24px; background:rgba(0,0,0,0.72); z-index:10; }
.qr-modal.open { display:flex; }
.qr-modal img { width:min(70vw,520px); height:min(70vw,520px); padding:18px; border-radius:4px; background:#fff; }
</style> </style>
<div id="qrModal" class="qr-modal"><img alt="Expanded QR" /></div>
<script> <script>
const PAGE_SIZE = 5;
let selectedPage = 0;
const pickBtn = document.getElementById('pick'); const pickBtn = document.getElementById('pick');
const pickFolderBtn = document.getElementById('pickFolder');
const clearBtn = document.getElementById('clear'); const clearBtn = document.getElementById('clear');
const uploadBtn = document.getElementById('upload'); const uploadBtn = document.getElementById('upload');
const fileNameEl = document.getElementById('fileName'); const fileNameEl = document.getElementById('fileName');
@@ -68,6 +87,7 @@ const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const saveAllBtn = document.getElementById('saveAll'); const saveAllBtn = document.getElementById('saveAll');
const regenAllBtn = document.getElementById('regenAll'); const regenAllBtn = document.getElementById('regenAll');
const qrModal = document.getElementById('qrModal');
let selectedPaths = []; let selectedPaths = [];
let resultItems = []; let resultItems = [];
@@ -76,15 +96,38 @@ function basename(p) {
return i >= 0 ? p.slice(i + 1) : p; return i >= 0 ? p.slice(i + 1) : p;
} }
function renderSelection() { function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedPaths.length) { if (!selectedPaths.length) {
fileNameEl.textContent = '(no files)'; fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = ''; fileListEl.innerHTML = '';
clearBtn.style.display = 'none';
return; return;
} }
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's'); fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join(''); const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
clearBtn.style.display = ''; selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visiblePaths.map((p, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => { selectedPaths.splice(Number(btn.dataset.index), 1); renderSelection(); });
});
if (selectedPaths.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
} }
saveAllBtn.addEventListener('click', async () => { saveAllBtn.addEventListener('click', async () => {
@@ -114,15 +157,27 @@ regenAllBtn.addEventListener('click', () => {
clearBtn.addEventListener('click', () => { clearBtn.addEventListener('click', () => {
selectedPaths = []; selectedPaths = [];
selectedPage = 0;
renderSelection(); renderSelection();
}); });
pickFolderBtn.addEventListener('click', async () => {
const r = await fetch('/api/applovin/pickFolder', { method: 'POST' });
const j = await r.json();
if (j.files && j.files.length) {
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added);
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
renderSelection();
}
});
pickBtn.addEventListener('click', async () => { pickBtn.addEventListener('click', async () => {
const r = await fetch('/api/applovin/pick', { method: 'POST' }); const r = await fetch('/api/applovin/pick', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.files && j.files.length) { if (j.files && j.files.length) {
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p)); const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added); selectedPaths = selectedPaths.concat(added);
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
renderSelection(); renderSelection();
} }
}); });
@@ -136,8 +191,8 @@ uploadBtn.addEventListener('click', async () => {
} }
uploadBtn.disabled = true; uploadBtn.disabled = true;
resultItems = []; resultItems = [];
saveAllBtn.style.display = 'none'; saveAllBtn.disabled = true;
regenAllBtn.style.display = 'none'; regenAllBtn.disabled = true;
try { try {
const res = await fetch('/api/applovin/upload', { const res = await fetch('/api/applovin/upload', {
method: 'POST', method: 'POST',
@@ -174,24 +229,40 @@ function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
} }
function ensureResultsTable() {
let tbody = resultsEl.querySelector('tbody');
if (tbody) return tbody;
resultsEl.innerHTML = '<table class="data-table qr-results"><thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead><tbody></tbody></table>';
return resultsEl.querySelector('tbody');
}
qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
function handle(m) { function handle(m) {
if (m.type === 'status') { if (m.type === 'status') {
statusEl.textContent = m.message; statusEl.textContent = m.message;
} else if (m.type === 'fileError') { } else if (m.type === 'fileError') {
const wrap = document.createElement('div'); const tbody = ensureResultsTable();
wrap.className = 'result'; resultsEl.querySelector('table').classList.add('has-errors');
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' + const row = document.createElement('tr');
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>'; row.innerHTML = '<td class="mono wrap">' + escapeHtml(m.name) + '</td><td class="muted">-</td><td class="muted">-</td><td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
resultsEl.appendChild(wrap); tbody.appendChild(row);
} else if (m.type === 'fileResult') { } else if (m.type === 'fileResult') {
const wrap = document.createElement('div'); const tbody = ensureResultsTable();
wrap.className = 'result'; const row = document.createElement('tr');
wrap.innerHTML = const file = document.createElement('td');
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' + file.className = 'mono wrap';
'<div style="text-align:center;margin-bottom:8px;">' + file.textContent = m.name;
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' + const qr = document.createElement('td');
'</div>' + qr.className = 'qr-cell';
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>'; qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
qr.querySelector('img').addEventListener('click', (event) => {
qrModal.querySelector('img').src = event.currentTarget.src;
qrModal.classList.add('open');
});
const actionCell = document.createElement('td');
const error = document.createElement('td');
error.className = 'error-col';
error.innerHTML = '<span class="muted">-</span>';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'result-actions'; actions.className = 'result-actions';
const openBtn = document.createElement('button'); const openBtn = document.createElement('button');
@@ -218,13 +289,12 @@ function handle(m) {
} }
}; };
actions.appendChild(openBtn); actions.appendChild(saveQr); actions.appendChild(openBtn); actions.appendChild(saveQr);
wrap.appendChild(actions); actionCell.appendChild(actions);
resultsEl.appendChild(wrap); row.append(file, qr, actionCell, error);
tbody.appendChild(row);
resultItems.push({ name: m.name, url: m.qrImg }); resultItems.push({ name: m.name, url: m.qrImg });
if (resultItems.length >= 2) { saveAllBtn.disabled = resultItems.length === 0;
saveAllBtn.style.display = ''; regenAllBtn.disabled = resultItems.length === 0;
regenAllBtn.style.display = '';
}
} else if (m.type === 'done') { } else if (m.type === 'done') {
statusEl.innerHTML = '<span class="ok">Done.</span>'; statusEl.innerHTML = '<span class="ok">Done.</span>';
} }
@@ -232,7 +302,7 @@ function handle(m) {
</script> </script>
` `
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body))) _, _ = w.Write([]byte(Page("/applovin", "AppLovin Playable Preview", body)))
} }
func ApplovinPick(w http.ResponseWriter, r *http.Request) { func ApplovinPick(w http.ResponseWriter, r *http.Request) {
@@ -253,6 +323,23 @@ func ApplovinPick(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"files": files}) writeJSON(w, map[string]any{"files": files})
} }
func ApplovinPickFolder(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
dir := PickFolder("Select folder", cfg.LastPickDir)
if dir == "" {
writeJSON(w, map[string]any{"files": []any{}})
return
}
cfg.LastPickDir = dir
_ = SaveConfig(cfg)
paths := collectHTMLFiles(dir)
files := make([]map[string]string, 0, len(paths))
for _, p := range paths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
func ApplovinUpload(w http.ResponseWriter, r *http.Request) { func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Paths []string `json:"paths"` Paths []string `json:"paths"`

View File

@@ -12,83 +12,119 @@ import (
func Base64Page(w http.ResponseWriter, r *http.Request) { func Base64Page(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>Base64 Asset Scanner</h2> <header class="tool-header">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;"> <h2 class="tool-title">Base64 Scanner</h2>
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" /> <p class="tool-description">Scan selected HTML files for asset references that are not embedded as base64 data URIs.</p>
<button id="pickFolder">Browse Folder...</button> </header>
<button id="pickFiles">Pick Files...</button>
<button id="scan">Scan</button> <section class="tool-panel">
</div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div id="selection" class="hint"></div> <div class="panel-body">
<div id="status" style="margin-top:8px;"></div> <div class="control-group">
<div id="results" style="margin-top:8px;"></div> <div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row"><button id="scan">Scan</button></div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="results" class="results-panel"></div>
<style> <style>
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; } .asset-list { margin:0; padding-left:16px; }
.mark { width:14px; flex-shrink:0; font-weight:bold; } .asset-list li + li { margin-top:3px; }
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
.file-name { word-break:break-all; }
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
.summary { opacity:0.8; margin-bottom:6px; }
</style> </style>
<script> <script>
const folderEl = document.getElementById('folder'); const PAGE_SIZE = 5;
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const selectionEl = document.getElementById('selection'); const selectionEl = document.getElementById('selection');
let pickedFiles = []; let pickedFiles = [];
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; } let selectedPage = 0;
folderEl.addEventListener('input', clearFiles); function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!pickedFiles.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visible.map((p, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
});
if (pickedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
function addPaths(paths) {
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
}
renderSelection();
document.getElementById('pickFolder').addEventListener('click', async () => { document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFolder', { method: 'POST' }); const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { folderEl.value = j.path; clearFiles(); } addPaths(j.paths || []);
}); });
document.getElementById('pickFiles').addEventListener('click', async () => { document.getElementById('pickFiles').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFiles', { method: 'POST' }); const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.paths && j.paths.length) { addPaths(j.paths || []);
pickedFiles = j.paths; });
folderEl.value = ''; document.getElementById('clear').addEventListener('click', () => {
selectionEl.textContent = pickedFiles.length + ' file(s) selected'; pickedFiles = [];
} selectedPage = 0;
statusEl.textContent = '';
resultsEl.innerHTML = '';
renderSelection();
}); });
document.getElementById('scan').addEventListener('click', async () => { document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
let payload; if (!pickedFiles.length) { statusEl.textContent = 'Pick files first.'; return; }
if (pickedFiles.length) payload = { files: pickedFiles }; const payload = { files: pickedFiles };
else {
const folder = folderEl.value.trim();
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
payload = { folder };
}
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json(); const j = await r.json();
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; } if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
const total = j.results.length; const total = j.results.length;
const flagged = j.results.filter(r => !r.ok).length; const flagged = j.results.filter(r => !r.ok).length;
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>'; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
resultsEl.innerHTML = ''; if (!total) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead><tbody></tbody></table>';
const tbody = resultsEl.querySelector('tbody');
for (const rr of j.results) { for (const rr of j.results) {
const row = document.createElement('div'); const row = document.createElement('tr');
row.className = 'row-result'; const assets = rr.ok ? '<span class="muted">None</span>' : '<ul class="asset-list mono">' + rr.assets.map(a => '<li>' + escapeHtml(a) + '</li>').join('') + '</ul>';
const mark = document.createElement('span'); row.innerHTML = '<td>' + (rr.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>') + '</td>' +
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad'); '<td class="mono wrap">' + escapeHtml(rr.file) + '</td><td>' + rr.assets.length + '</td><td class="wrap">' + assets + '</td>';
mark.textContent = rr.ok ? '✓' : '✗'; tbody.appendChild(row);
const name = document.createElement('span');
name.className = 'file-name';
name.textContent = rr.file;
row.appendChild(mark); row.appendChild(name);
if (!rr.ok) {
const a = document.createElement('span');
a.className = 'assets';
a.textContent = '— ' + rr.assets.join(', ');
row.appendChild(a);
}
resultsEl.appendChild(row);
} }
}); });
</script> </script>
@@ -99,7 +135,11 @@ document.getElementById('scan').addEventListener('click', async () => {
func Base64PickFolder(w http.ResponseWriter, r *http.Request) { func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
p := PickFolder("Select folder to scan", "") p := PickFolder("Select folder to scan", "")
writeJSON(w, map[string]any{"path": p}) if p == "" {
writeJSON(w, map[string]any{"paths": []string{}})
return
}
writeJSON(w, map[string]any{"paths": collectHTMLFiles(p)})
} }
func Base64PickFiles(w http.ResponseWriter, r *http.Request) { func Base64PickFiles(w http.ResponseWriter, r *http.Request) {

View File

@@ -12,19 +12,37 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
if n.Path == "/" { if n.Path == "/" {
continue continue
} }
label := n.Label badge := ""
if n.Beta { if n.Beta {
label += ` <span style="font-size:10px;opacity:0.65;text-transform:uppercase;">Beta</span>` badge = ` <span class="beta-pill">Beta</span>`
} }
items.WriteString(`<li><a href="` + n.Path + `" style="color:#9cdcfe;">` + label + `</a> — ` + n.Description + `</li>`) items.WriteString(`<a class="home-tool" href="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></a>`)
} }
body := ` body := `
<h2>HPL Toolbox</h2> <header class="tool-header">
<p class="hint">Standalone build. Pick a tool from the top bar.</p> <h2 class="tool-title">HPL Toolbox</h2>
<ul style="line-height:1.9;"> <p class="tool-description">Standalone build. Pick a tool to open.</p>
</header>
<div class="home-tools">
` + items.String() + ` ` + items.String() + `
</ul> </div>
<style>
.home-tools { display:flex; flex-direction:column; gap:8px; max-width:620px; }
.home-tool {
display:block;
min-height:52px;
padding:9px 11px;
color:#ddd;
text-decoration:none;
background:#2a2d2e;
border:1px solid #333;
border-radius:5px;
}
.home-tool:hover { background:#33373a; border-color:#007fd4; }
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
</style>
` `
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/", "Home", body))) _, _ = w.Write([]byte(Page("/", "Home", body)))

View File

@@ -3,13 +3,25 @@ package main
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv"
"strings" "strings"
) )
const SharedCSS = ` const SharedCSS = `
:root { color-scheme: dark; } :root {
color-scheme: dark;
--tool-gap-sm: 8px;
--tool-gap-md: 12px;
--tool-gap-lg: 16px;
--tool-radius: 5px;
--tool-border: #333;
--tool-panel: #252526;
}
* { box-sizing: border-box; }
html { min-height: 100%; }
body { body {
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e1e; color: #ddd; background: #1e1e1e; color: #ddd;
margin: 0; padding: 0; margin: 0; padding: 0;
@@ -19,17 +31,49 @@ const SharedCSS = `
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333; padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
font-size: 13px; font-size: 13px;
} }
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; } .topbar a { color: #ddd; text-decoration: none; padding: 5px 10px; border-radius: 4px; }
.topbar a:hover { background: #2a2d2e; } .topbar a:hover { background: #2a2d2e; }
.topbar a.active { background: #094771; color: #fff; } .topbar a.active { background: #094771; color: #fff; }
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; } .beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; } .topbar-spacer { margin-left: auto; }
.content { padding: 16px; max-width: 980px; margin: 0 auto; } .content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
.beta-tools-footer { max-width: 980px; margin: 24px auto 16px auto; padding: 12px 16px 0; border-top: 1px solid #333; } .app-footer {
.beta-tools-footer button { width: 100%; text-align: left; background: #3a3d41; color: #ddd; } width: 100%;
.beta-tools-footer button:hover { background: #45494e; } max-width: 1100px;
.beta-note { display: block; margin-top: 3px; opacity: 0.65; font-size: 11px; } margin: auto auto 0;
h2 { margin-top: 0; font-weight: 500; } padding: 8px 18px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #a7a7a7;
font-size: 10px;
border-top: 1px solid #333;
}
.beta-tools-toggle {
padding: 0;
background: transparent;
color: #a7a7a7;
border: 0;
cursor: pointer;
font: inherit;
opacity: 0.72;
}
.beta-tools-toggle:hover { background: transparent; opacity: 1; text-decoration: underline; }
.app-footer-version { opacity: 0.75; text-align: right; }
h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
.tool-header { margin-bottom: var(--tool-gap-lg); }
.tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
.tool-description, .section-description, .muted { color: #a7a7a7; }
.tool-description { margin: 5px 0 0; max-width: 780px; font-size: 12px; line-height: 1.45; }
.tool-panel { margin-bottom: var(--tool-gap-lg); border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); overflow: hidden; }
.panel-header { padding: 10px 12px; border-bottom: 1px solid var(--tool-border); background: var(--tool-panel); }
.panel-title { margin: 0; color: #ddd; font-size: 12px; font-weight: 700; letter-spacing: 0.45px; text-transform: uppercase; }
.panel-body { padding: 12px; }
.control-group + .control-group { margin-top: var(--tool-gap-md); }
.control-label { margin: 0 0 6px; color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; }
.file-row, .field-row, .action-row { display: flex; align-items: center; gap: var(--tool-gap-sm); flex-wrap: wrap; }
.action-row { margin-top: var(--tool-gap-md); }
input[type=text], input[type=date], select, textarea { input[type=text], input[type=date], select, textarea {
box-sizing: border-box; padding: 6px 8px; box-sizing: border-box; padding: 6px 8px;
background: #3c3c3c; color: #ddd; background: #3c3c3c; color: #ddd;
@@ -46,7 +90,30 @@ const SharedCSS = `
button:hover { background: #1177bb; } button:hover { background: #1177bb; }
button.secondary { background: #3a3d41; color: #ddd; } button.secondary { background: #3a3d41; color: #ddd; }
button.secondary:hover { background: #45494e; } button.secondary:hover { background: #45494e; }
button.danger { background: transparent; color: #f48771; border: 1px solid #444; }
button.danger:hover { background: rgba(244,135,113,0.14); }
button:disabled { opacity: 0.5; cursor: not-allowed; } button:disabled { opacity: 0.5; cursor: not-allowed; }
.status-panel, .results-panel, .result-card { border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); }
.status-panel { min-height: 30px; margin-top: var(--tool-gap-md); padding: 8px 10px; white-space: pre-wrap; }
.status-panel:empty { display: none; }
.results-panel { margin-top: var(--tool-gap-md); overflow: auto; }
.results-panel:empty { display: none; }
.result-card { padding: 10px; word-break: break-word; }
.data-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.data-table th, .data-table td { padding: 8px 10px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--tool-border); }
.data-table th { color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; background: var(--tool-panel); }
.data-table tr:last-child td { border-bottom: none; }
.mono { font-family: Consolas, "Courier New", monospace; font-size: 12px; }
.wrap { overflow-wrap: anywhere; word-break: break-word; }
.file-name { color: #a7a7a7; font-size: 12px; overflow-wrap: anywhere; }
.selected-list, .selected-files { margin-top: 8px; }
.remove-selected, .remove-btn { min-width: 28px; width: 28px; padding: 3px 0; color: #f48771; font-size: 16px; line-height: 1; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.badge { display: inline-flex; align-items: center; min-height: 18px; padding: 1px 6px; border-radius: 999px; border: 1px solid #444; font-size: 11px; line-height: 1.2; white-space: nowrap; }
.badge.ok { color: #89d185; background: rgba(137,209,133,0.12); }
.badge.err { color: #f48771; background: rgba(244,135,113,0.14); }
.badge.warn { color: #ffd580; background: rgba(210,153,34,0.16); }
.is-hidden { display: none !important; }
.err { color: #f48771; white-space: pre-wrap; } .err { color: #f48771; white-space: pre-wrap; }
.ok { color: #89d185; } .ok { color: #89d185; }
.hint { font-size: 12px; opacity: 0.7; } .hint { font-size: 12px; opacity: 0.7; }
@@ -62,7 +129,7 @@ type navItem struct {
var navItems = []navItem{ var navItems = []navItem{
{Path: "/", Label: "Home"}, {Path: "/", Label: "Home"},
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"}, {Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
{Path: "/applovin", Label: "AppLovin Upload", Description: "Upload to p.applov.in (QR preview)"}, {Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"}, {Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true}, {Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true},
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"}, {Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
@@ -118,11 +185,9 @@ func Page(activePath, title, body string) string {
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + label + `</a>`) tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + label + `</a>`)
} }
betaButtonLabel := "Enable Beta Tools" betaButtonLabel := "Show beta"
betaButtonNote := "Hidden beta tools: " + strconv.Itoa(betaToolCount())
if betaToolsEnabled { if betaToolsEnabled {
betaButtonLabel = "Disable Beta Tools" betaButtonLabel = "Hide beta"
betaButtonNote = "Beta tools are visible in this standalone launcher."
} }
return `<!DOCTYPE html> return `<!DOCTYPE html>
@@ -131,10 +196,11 @@ func Page(activePath, title, body string) string {
<title>` + title + ` — HPL Toolbox</title> <title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style> <style>` + SharedCSS + `</style>
</head><body> </head><body>
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div> <div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
<div class="content">` + body + `</div> <div class="content">` + body + `</div>
<div class="beta-tools-footer"> <div class="app-footer">
<button id="betaToolsToggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `<span class="beta-note">` + betaButtonNote + `</span></button> <button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span>
</div> </div>
<script> <script>
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => { document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {

View File

@@ -123,10 +123,12 @@ func buildMux() *http.ServeMux {
// PLEC // PLEC
mux.HandleFunc("POST /api/plec/pick", PlecPick) mux.HandleFunc("POST /api/plec/pick", PlecPick)
mux.HandleFunc("POST /api/plec/pickFolder", PlecPickFolder)
mux.HandleFunc("POST /api/plec/upload", PlecUpload) mux.HandleFunc("POST /api/plec/upload", PlecUpload)
// AppLovin // AppLovin
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick) mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
mux.HandleFunc("POST /api/applovin/pickFolder", ApplovinPickFolder)
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload) mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR) mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs) mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
@@ -143,6 +145,7 @@ func buildMux() *http.ServeMux {
// Send To Mobile // Send To Mobile
mux.HandleFunc("POST /api/mobile/pick", MobilePick) mux.HandleFunc("POST /api/mobile/pick", MobilePick)
mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder)
mux.HandleFunc("POST /api/mobile/start", MobileStart) mux.HandleFunc("POST /api/mobile/start", MobileStart)
mux.HandleFunc("POST /api/mobile/stop", MobileStop) mux.HandleFunc("POST /api/mobile/stop", MobileStop)

View File

@@ -2,10 +2,10 @@ package main
import ( import (
"context" "context"
"crypto/rand"
_ "embed" _ "embed"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"crypto/rand"
"fmt" "fmt"
"html" "html"
"net" "net"
@@ -27,7 +27,11 @@ type activeShare struct {
listener net.Listener listener net.Listener
port int port int
token string token string
fileBuf []byte files []sharedFile
}
type sharedFile struct {
buf []byte
filename string filename string
} }
@@ -54,81 +58,143 @@ func MobileQrScript(w http.ResponseWriter, r *http.Request) {
func MobilePage(w http.ResponseWriter, r *http.Request) { func MobilePage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>Send To Mobile</h2> <header class="tool-header">
<p class="hint" style="margin-top:0;"> <h2 class="tool-title">Send To Mobile</h2>
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. <p class="tool-description">Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.</p>
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>. </header>
</p>
<div style="margin-bottom:14px;"> <section class="tool-panel">
<div style="display:flex;align-items:center;gap:8px;"> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<button id="pick" class="secondary">Choose...</button> <div class="panel-body">
<span id="fileName" class="hint">(no file)</span> <div class="control-group">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pick" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
<div id="fileList" class="selected-list"></div>
</div>
<div class="action-row">
<button id="start">Start</button>
<button id="stop" class="secondary" disabled>Stop</button>
</div>
<div id="status" class="status-panel"></div>
</div> </div>
<input type="hidden" id="filePath" /> </section>
<div id="ifaceWrap" class="control-group" style="display:none;margin-bottom:14px;">
<div class="control-label">Network Interfaces</div>
<div id="ifaceList"></div>
</div> </div>
<div style="margin-bottom:14px;"> <div id="shareInfo" class="result-card" style="display:none;">
<button id="start">Start sharing</button> <div id="qr" style="background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
<button id="stop" class="secondary" disabled>Stop</button> <div class="muted" style="margin-top:8px;font-size:12px;">
</div> First run on Windows may show a firewall prompt - allow access for Private networks.
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
</div>
<div id="shareInfo" style="display:none;">
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button id="copyUrl" class="secondary">Copy URL</button>
<button id="openUrl" class="secondary">Open in browser</button>
</div>
<div class="hint" style="margin-top:8px;">
First run on Windows may show a firewall prompt — allow access for "Private networks".
</div> </div>
</div> </div>
<div id="status" style="margin-top:12px;min-height:18px;"></div>
<script src="/assets/qrcode.min.js"></script> <script src="/assets/qrcode.min.js"></script>
<script> <script>
const PAGE_SIZE = 5;
const pickFolderBtn = document.getElementById('pickFolder');
const pickBtn = document.getElementById('pick'); const pickBtn = document.getElementById('pick');
const clearBtn = document.getElementById('clear');
const startBtn = document.getElementById('start'); const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop'); const stopBtn = document.getElementById('stop');
const fileNameEl = document.getElementById('fileName'); const fileNameEl = document.getElementById('fileName');
const filePathEl = document.getElementById('filePath'); const fileListEl = document.getElementById('fileList');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const shareInfo = document.getElementById('shareInfo'); const shareInfo = document.getElementById('shareInfo');
const ifaceWrap = document.getElementById('ifaceWrap'); const ifaceWrap = document.getElementById('ifaceWrap');
const ifaceList = document.getElementById('ifaceList'); const ifaceList = document.getElementById('ifaceList');
const qrEl = document.getElementById('qr'); const qrEl = document.getElementById('qr');
const urlTextEl = document.getElementById('urlText');
let currentUrls = []; let currentUrls = [];
let selectedIdx = 0; let selectedIdx = 0;
let selectedFiles = [];
let selectedPaths = [];
let selectedPage = 0;
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!selectedFiles.length) {
fileNameEl.textContent = '(no files selected)';
fileListEl.innerHTML = '';
return;
}
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visible = selectedFiles.slice(start, start + PAGE_SIZE);
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visible.map((file, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
const index = Number(btn.dataset.index);
selectedFiles.splice(index, 1);
selectedPaths.splice(index, 1);
renderSelection();
});
});
if (selectedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
fileListEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
function addFiles(files) {
const existing = new Set(selectedFiles.map(f => f.path));
selectedFiles = selectedFiles.concat((files || []).filter(f => !existing.has(f.path)));
selectedPaths = selectedFiles.map(f => f.path);
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
renderSelection();
}
renderSelection();
pickFolderBtn.addEventListener('click', async () => {
const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
const j = await r.json();
addFiles(j.files || []);
});
pickBtn.addEventListener('click', async () => { pickBtn.addEventListener('click', async () => {
const r = await fetch('/api/mobile/pick', { method:'POST' }); const r = await fetch('/api/mobile/pick', { method:'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { addFiles(j.files || []);
filePathEl.value = j.path; });
fileNameEl.textContent = j.name; clearBtn.addEventListener('click', () => {
} selectedFiles = [];
selectedPaths = [];
selectedPage = 0;
statusEl.textContent = '';
renderSelection();
}); });
startBtn.addEventListener('click', async () => { startBtn.addEventListener('click', async () => {
statusEl.innerHTML = ''; statusEl.innerHTML = '';
if (!filePathEl.value) { if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>'; statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return; return;
} }
startBtn.disabled = true; startBtn.disabled = true;
statusEl.textContent = 'Starting server...'; statusEl.textContent = 'Starting server...';
const r = await fetch('/api/mobile/start', { const r = await fetch('/api/mobile/start', {
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ path: filePathEl.value }), body: JSON.stringify({ paths: selectedPaths }),
}); });
const j = await r.json(); const j = await r.json();
if (j.error) { if (j.error) {
@@ -154,18 +220,8 @@ stopBtn.addEventListener('click', async () => {
stopBtn.disabled = true; stopBtn.disabled = true;
}); });
document.getElementById('copyUrl').addEventListener('click', () => {
const url = currentUrls[selectedIdx]?.url || '';
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
});
document.getElementById('openUrl').addEventListener('click', () => {
const url = currentUrls[selectedIdx]?.url || '';
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
});
function renderQr(url) { function renderQr(url) {
qrEl.innerHTML = ''; qrEl.innerHTML = '';
urlTextEl.textContent = url;
try { try {
const qr = qrcode(0, 'M'); const qr = qrcode(0, 'M');
qr.addData(url); qr.addData(url);
@@ -182,19 +238,18 @@ function renderIfaces(urls) {
ifaceList.innerHTML = ''; ifaceList.innerHTML = '';
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; } if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
ifaceWrap.style.display = ''; ifaceWrap.style.display = '';
ifaceList.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table"><thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead><tbody></tbody></table></div>';
const tbody = ifaceList.querySelector('tbody');
urls.forEach((u, i) => { urls.forEach((u, i) => {
const id = 'iface_' + i; const id = 'iface_' + i;
const label = document.createElement('label'); const row = document.createElement('tr');
label.style.fontSize = '12px'; row.innerHTML = '<td style="text-align:center;"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
label.style.cursor = 'pointer'; '<td class="mono wrap">' + u.ip + '</td><td class="wrap">' + u.iface + '</td>';
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' + row.querySelector('input').addEventListener('change', () => {
(i === selectedIdx ? ' checked' : '') + ' /> ' +
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
label.querySelector('input').addEventListener('change', () => {
selectedIdx = i; selectedIdx = i;
renderQr(urls[i].url); renderQr(urls[i].url);
}); });
ifaceList.appendChild(label); tbody.appendChild(row);
}); });
} }
</script> </script>
@@ -207,35 +262,65 @@ func MobilePick(w http.ResponseWriter, r *http.Request) {
picked := PickFiles( picked := PickFiles(
"Select HTML", "Select HTML",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
false, "", true, "",
) )
if len(picked) == 0 { if len(picked) == 0 {
writeJSON(w, map[string]any{"path": nil}) writeJSON(w, map[string]any{"files": []any{}})
return return
} }
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])}) files := make([]map[string]string, 0, len(picked))
for _, p := range picked {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
func MobilePickFolder(w http.ResponseWriter, r *http.Request) {
dir := PickFolder("Select folder", "")
if dir == "" {
writeJSON(w, map[string]any{"files": []any{}})
return
}
paths := collectHTMLFiles(dir)
files := make([]map[string]string, 0, len(paths))
for _, p := range paths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
} }
func MobileStart(w http.ResponseWriter, r *http.Request) { func MobileStart(w http.ResponseWriter, r *http.Request) {
stopActiveShare() stopActiveShare()
var req struct { var req struct {
Path string `json:"path"` Path string `json:"path"`
Paths []string `json:"paths"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) writeJSON(w, map[string]any{"error": err.Error()})
return return
} }
if req.Path == "" || !fileExists(req.Path) { paths := req.Paths
if len(paths) == 0 && req.Path != "" {
paths = []string{req.Path}
}
if len(paths) == 0 {
writeJSON(w, map[string]any{"error": "File missing."}) writeJSON(w, map[string]any{"error": "File missing."})
return return
} }
fileBuf, err := os.ReadFile(req.Path) files := make([]sharedFile, 0, len(paths))
if err != nil { for _, p := range paths {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()}) if p == "" || !fileExists(p) {
return writeJSON(w, map[string]any{"error": "File missing."})
return
}
fileBuf, err := os.ReadFile(p)
if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
return
}
files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)})
} }
filename := filepath.Base(req.Path)
// 6 random bytes -> base64url, no padding // 6 random bytes -> base64url, no padding
tokenBytes := make([]byte, 6) tokenBytes := make([]byte, 6)
@@ -257,8 +342,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
listener: listener, listener: listener,
port: port, port: port,
token: token, token: token,
fileBuf: fileBuf, files: files,
filename: filename,
} }
share.server = &http.Server{Handler: shareHandler(share)} share.server = &http.Server{Handler: shareHandler(share)}
@@ -288,6 +372,10 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token), URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
}) })
} }
filename := fmt.Sprintf("%d files", len(files))
if len(files) == 1 {
filename = files[0].filename
}
writeJSON(w, map[string]any{"urls": urls, "filename": filename}) writeJSON(w, map[string]any{"urls": urls, "filename": filename})
} }
@@ -308,19 +396,53 @@ func shareHandler(s *activeShare) http.Handler {
case "", "/": case "", "/":
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(chooserPage(s.filename))) _, _ = w.Write([]byte(chooserPage(s.files)))
case "/view": case "/view":
fallthrough
case "/view/0":
if len(s.files) == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.fileBuf) _, _ = w.Write(s.files[0].buf)
case "/file": case "/file":
safeName := safeFilename(s.filename) fallthrough
case "/file/0":
if len(s.files) == 0 {
http.NotFound(w, r)
return
}
safeName := safeFilename(s.files[0].filename)
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`) w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf))) w.Header().Set("Content-Length", strconv.Itoa(len(s.files[0].buf)))
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(s.fileBuf) _, _ = w.Write(s.files[0].buf)
default: default:
if strings.HasPrefix(rest, "/view/") || strings.HasPrefix(rest, "/file/") {
parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) == 2 {
idx, err := strconv.Atoi(parts[1])
if err == nil && idx >= 0 && idx < len(s.files) {
file := s.files[idx]
if parts[0] == "view" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(file.buf)
return
}
safeName := safeFilename(file.filename)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
w.Header().Set("Content-Length", strconv.Itoa(len(file.buf)))
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(file.buf)
return
}
}
}
http.NotFound(w, r) http.NotFound(w, r)
} }
}) })
@@ -330,8 +452,14 @@ var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") } func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
func chooserPage(filename string) string { func chooserPage(files []sharedFile) string {
escName := html.EscapeString(filename) var items strings.Builder
for i, file := range files {
escName := html.EscapeString(file.filename)
items.WriteString(`<div class="item"><div class="file">` + escName + `</div>
<a class="btn view" href="view/` + strconv.Itoa(i) + `">View in browser</a>
<a class="btn dl" href="file/` + strconv.Itoa(i) + `" download="` + escName + `">Download .html</a></div>`)
}
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html><head> <html><head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -341,7 +469,8 @@ func chooserPage(filename string) string {
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 24px; background: #111; color: #eee; } margin: 0; padding: 24px; background: #111; color: #eee; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; } h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; } .item { margin-bottom: 26px; }
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px; a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; } font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
a.view { background: #2d7dff; color: white; } a.view { background: #2d7dff; color: white; }
@@ -349,9 +478,7 @@ func chooserPage(filename string) string {
</style></head> </style></head>
<body> <body>
<h1>HPL Toolbox</h1> <h1>HPL Toolbox</h1>
<div class="file">` + escName + `</div> ` + items.String() + `
<a class="btn view" href="view">View in browser</a>
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
</body></html>` </body></html>`
} }

View File

@@ -41,17 +41,26 @@ type mraidContext struct {
func MraidPage(w http.ResponseWriter, r *http.Request) { func MraidPage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>MRAID Checker</h2> <header class="tool-header">
<p class="hint">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p> <h2 class="tool-title">MRAID Checker</h2>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;"> <p class="tool-description">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" /> </header>
<button id="pickFolder">Browse Folder...</button> <section class="tool-panel">
<button id="pickFiles">Pick Files...</button> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<button id="scan">Scan</button> <div class="panel-body">
</div> <div class="control-group">
<div id="selection" class="hint"></div> <div class="file-row">
<div id="status" style="margin-top:8px;"></div> <button id="pickFolder" class="secondary">Select Folder</button>
<div id="results" style="margin-top:8px;"></div> <button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row"><button id="scan">Scan</button></div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="results" class="results-panel"></div>
<style> <style>
.row-result { padding:8px 0; border-bottom:1px solid #333; } .row-result { padding:8px 0; border-bottom:1px solid #333; }
@@ -75,35 +84,73 @@ const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const selectionEl = document.getElementById('selection'); const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFiles = []; let pickedFiles = [];
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; } let selectedPage = 0;
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); } function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); }
folderEl.addEventListener('input', clearFiles); function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
if (!pickedFiles.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
selectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
visible.map((p, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeText(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
});
if (pickedFiles.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
function addPaths(paths) {
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
}
renderSelection();
document.getElementById('pickFolder').addEventListener('click', async () => { document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' }); const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { folderEl.value = j.path; clearFiles(); } addPaths(j.paths || []);
}); });
document.getElementById('pickFiles').addEventListener('click', async () => { document.getElementById('pickFiles').addEventListener('click', async () => {
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' }); const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
const j = await r.json(); const j = await r.json();
if (j.paths && j.paths.length) { addPaths(j.paths || []);
pickedFiles = j.paths; });
folderEl.value = ''; document.getElementById('clear').addEventListener('click', () => {
selectionEl.textContent = pickedFiles.length + ' file(s) selected'; pickedFiles = [];
} selectedPage = 0;
statusEl.textContent = '';
resultsEl.innerHTML = '';
renderSelection();
}); });
document.getElementById('scan').addEventListener('click', async () => { document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
let payload; if (!pickedFiles.length) { statusEl.textContent = 'Pick files first.'; return; }
if (pickedFiles.length) payload = { files: pickedFiles }; const payload = { files: pickedFiles };
else {
const folder = folderEl.value.trim();
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
payload = { folder };
}
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json(); const j = await r.json();
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; } if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; }
@@ -114,14 +161,17 @@ document.getElementById('scan').addEventListener('click', async () => {
const skipped = Number(j.skipped || 0); const skipped = Number(j.skipped || 0);
const skippedReason = j.skippedReason || 'file(s).'; const skippedReason = j.skippedReason || 'file(s).';
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : ''; const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>'; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
resultsEl.innerHTML = ''; if (!total) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
const tbody = resultsEl.querySelector('tbody');
for (const rr of j.results) { for (const rr of j.results) {
const reqCount = rr.issues.filter(i => i.severity === 'requirement').length; const row = document.createElement('tr');
const bpCount = rr.issues.filter(i => i.severity === 'best-practice').length; const file = document.createElement('td');
const row = document.createElement('div'); file.className = 'mono wrap';
row.className = 'row-result'; file.textContent = rr.file;
row.innerHTML = '<div class="file-head"><span class="mark ' + (rr.ok ? 'ok' : 'bad') + '">' + (rr.ok ? 'OK' : 'X') + '</span><span class="file-name">' + escapeText(rr.file) + '</span><span class="counts">' + reqCount + ' req / ' + bpCount + ' bp</span></div>'; const result = document.createElement('td');
result.className = 'wrap';
if (!rr.ok) { if (!rr.ok) {
const list = document.createElement('ul'); const list = document.createElement('ul');
list.className = 'issues'; list.className = 'issues';
@@ -132,9 +182,12 @@ document.getElementById('scan').addEventListener('click', async () => {
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>'; item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
list.appendChild(item); list.appendChild(item);
} }
row.appendChild(list); result.appendChild(list);
} else {
result.innerHTML = '<span class="badge ok">OK</span>';
} }
resultsEl.appendChild(row); row.append(file, result);
tbody.appendChild(row);
} }
}); });
</script> </script>
@@ -144,7 +197,18 @@ document.getElementById('scan').addEventListener('click', async () => {
} }
func MraidPickFolder(w http.ResponseWriter, r *http.Request) { func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"path": PickFolder("Select folder to scan", "")}) p := PickFolder("Select folder to scan", "")
if p == "" {
writeJSON(w, map[string]any{"paths": []string{}})
return
}
files := []string{}
for _, f := range collectHTMLFiles(p) {
if isInSupportedMraidFolderPath(f) {
files = append(files, f)
}
}
writeJSON(w, map[string]any{"paths": files})
} }
func MraidPickFiles(w http.ResponseWriter, r *http.Request) { func MraidPickFiles(w http.ResponseWriter, r *http.Request) {

View File

@@ -27,62 +27,52 @@ type playworksNetworkResult struct {
func PlayworksPage(w http.ResponseWriter, r *http.Request) { func PlayworksPage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>Playworks Converter</h2> <header class="tool-header">
<h2 class="tool-title">Playworks Converter</h2>
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
</header>
<style> <style>
.section { margin-bottom:14px; } .net-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:4px 12px; }
.section-label { font-size:11px; text-transform:uppercase; letter-spacing:0.5px; opacity:0.65; margin-bottom:5px; } .net-row { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; user-select:none; border:1px solid #333; border-radius:4px; background:rgba(128,128,128,0.06); }
.row { display:flex; gap:6px; align-items:center; }
.net-grid { display:flex; flex-direction:column; gap:4px; }
.net-row { display:flex; align-items:center; gap:8px; padding:3px 0; cursor:pointer; user-select:none; }
.net-label { min-width:100px; font-weight:500; } .net-label { min-width:100px; font-weight:500; }
.net-tag { font-size:10px; opacity:0.6; font-weight:400; } .net-tag { font-size:10px; opacity:0.6; font-weight:400; }
.net-note { font-size:11px; opacity:0.6; } .net-note { font-size:11px; opacity:0.6; }
.toggle-row { display:flex; gap:8px; margin-bottom:6px; } .toggle-row { display:flex; gap:8px; margin-bottom:6px; }
.toggle-row button { font-size:11px; padding:2px 8px; } .toggle-row button { font-size:11px; padding:2px 8px; }
.actions { display:flex; gap:10px; align-items:center; margin-top:16px; }
#status { margin-top:12px; font-size:12px; opacity:0.75; min-height:16px; }
.results { margin-top:10px; display:flex; flex-direction:column; gap:3px; }
.res-row { display:flex; align-items:center; gap:8px; font-size:12px; font-family:Consolas, monospace; }
.mark { font-weight:bold; width:14px; flex-shrink:0; }
.ok { color:#3fb950; }
.bad { color:#f85149; }
.res-net { min-width:70px; font-weight:500; }
.res-file { opacity:0.65; word-break:break-all; }
.res-err { color:#f85149; opacity:0.9; }
hr { border:none; border-top:1px solid #333; margin:14px 0; }
</style> </style>
<div class="section"> <section class="tool-panel">
<div class="section-label">Source HTML (Playworks export)</div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="row"> <div class="panel-body">
<div class="control-group">
<div class="control-label">Source HTML (Playworks export)</div>
<div class="field-row">
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" /> <input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" />
<button id="pickSrc">Browse...</button> <button id="pickSrc" class="secondary">Select File</button>
</div>
</div> </div>
</div>
<div class="section"> <div class="control-group">
<div class="section-label">Base Filename</div> <div class="control-label">Base Filename</div>
<div class="row"> <div class="field-row">
<input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" /> <input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" />
</div> </div>
</div>
<div class="section">
<div class="section-label">Output Folder</div>
<div class="row">
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
<button id="pickOut">Browse...</button>
</div> </div>
</div>
<hr /> <div class="control-group">
<div class="control-label">Output Folder</div>
<div class="field-row">
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
<button id="pickOut" class="secondary">Select Folder</button>
</div>
</div>
<div class="section"> <div class="control-group">
<div class="section-label">Networks to generate</div> <div class="control-label">Networks to generate</div>
<div class="toggle-row"> <div class="toggle-row">
<button id="selectAll">All</button> <button id="selectAll" class="secondary">All</button>
<button id="selectNone">None</button> <button id="selectNone" class="secondary">None</button>
</div> </div>
<div class="net-grid"> <div class="net-grid">
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label> <label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label>
@@ -93,17 +83,19 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) {
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</span></label> <label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label> <label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label> <label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" checked /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label> <label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
</div>
</div> </div>
</div>
<div class="actions"> <div class="action-row">
<button id="convert" disabled>Convert</button> <button id="convert" disabled>Convert</button>
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button> <button id="openOut" class="secondary" disabled>Open Output Folder</button>
</div> </div>
<div id="status"></div> <div id="status" class="status-panel"></div>
<div class="results" id="results"></div> </div>
</section>
<div id="results" class="results-panel"></div>
<script> <script>
let outputDir = ''; let outputDir = '';
@@ -136,7 +128,7 @@ document.getElementById('pickOut').addEventListener('click', async () => {
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); } if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
}); });
document.getElementById('selectAll').addEventListener('click', () => { document.getElementById('selectAll').addEventListener('click', () => {
document.querySelectorAll('.net-cb').forEach(c => c.checked = true); document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
updateConvertBtn(); updateConvertBtn();
}); });
document.getElementById('selectNone').addEventListener('click', () => { document.getElementById('selectNone').addEventListener('click', () => {
@@ -159,7 +151,7 @@ document.getElementById('convert').addEventListener('click', async () => {
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag); const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
statusEl.textContent = 'Converting...'; statusEl.textContent = 'Converting...';
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
openOutBtn.style.display = 'none'; openOutBtn.disabled = true;
convertBtn.disabled = true; convertBtn.disabled = true;
const res = await fetch('/api/playworks/convert', { const res = await fetch('/api/playworks/convert', {
method:'POST', method:'POST',
@@ -173,28 +165,18 @@ document.getElementById('convert').addEventListener('click', async () => {
const results = msg.results || []; const results = msg.results || [];
const ok = results.filter(r => r.ok).length; const ok = results.filter(r => r.ok).length;
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.'; statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
resultsEl.innerHTML = ''; if (!results.length) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th>Output Path</th><th>Error</th></tr></thead><tbody></tbody></table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of results) { for (const r of results) {
const row = document.createElement('div'); const row = document.createElement('tr');
row.className = 'res-row'; row.innerHTML = '<td class="mono">' + r.network + '</td>' +
const mark = document.createElement('span'); '<td>' + (r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Error</span>') + '</td>' +
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad'); '<td class="mono wrap">' + (r.ok ? outputDisplayName(r.file) : '<span class="muted">-</span>') + '</td>' +
mark.textContent = r.ok ? '' : '✗'; '<td class="err wrap">' + (r.ok ? '' : (r.error || 'Unknown error')) + '</td>';
const net = document.createElement('span'); tbody.appendChild(row);
net.className = 'res-net';
net.textContent = r.network;
const detail = document.createElement('span');
if (r.ok) {
detail.className = 'res-file';
detail.textContent = outputDisplayName(r.file);
} else {
detail.className = 'res-err';
detail.textContent = r.error || 'Unknown error';
}
row.append(mark, net, detail);
resultsEl.appendChild(row);
} }
if (ok > 0) { outputDir = outEl.value; openOutBtn.style.display = ''; } if (ok > 0) { outputDir = outEl.value; openOutBtn.disabled = false; }
}); });
</script> </script>
` `

View File

@@ -17,42 +17,54 @@ import (
func PlecPage(w http.ResponseWriter, r *http.Request) { func PlecPage(w http.ResponseWriter, r *http.Request) {
body := ` body := `
<h2>PLEC Upload</h2> <header class="tool-header">
<p class="hint" style="margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p> <h2 class="tool-title">PLEC Upload</h2>
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
</header>
<table id="rows" style="width:100%;border-collapse:collapse;margin-bottom:12px;"> <section class="tool-panel">
<thead><tr> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th> <div class="panel-body">
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th> <div class="control-group">
<th></th> <div class="file-row">
</tr></thead> <button id="pickFolder" class="secondary">Select Folder</button>
<tbody></tbody> <button id="pickFiles" class="secondary">Select File(s)</button>
</table> <button id="clear" class="secondary">Clear</button>
</div>
<div style="display:flex;gap:8px;"> <div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
<button id="addRow" class="secondary">+ Add Row</button> <div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
<button id="upload">Upload</button> <table id="rows" class="data-table">
</div> <thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
<tbody></tbody>
<div id="status" style="margin-top:12px;min-height:20px;"></div> </table>
<div id="results"></div> </div>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<button id="openResult" class="secondary" disabled>Open</button>
<button id="copyResult" class="secondary" disabled>Copy Link</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<style> <style>
#rows td { padding:6px 8px; vertical-align:middle; border-bottom:1px solid #333; }
.file-cell { display:flex; align-items:center; gap:8px; }
.file-name { opacity:0.85; font-size:12px; word-break:break-all; }
.row-error { color:#f48771; font-size:12px; margin-top:4px; } .row-error { color:#f48771; font-size:12px; margin-top:4px; }
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; } #rows input[type=text] { width: 100%; }
.result a { color:#9cdcfe; }
.result-actions { margin-top:8px; display:flex; gap:8px; }
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
</style> </style>
<script> <script>
const PAGE_SIZE = 5;
let nextId = 1; let nextId = 1;
let rows = [];
let page = 0;
let previewUrl = '';
const tbody = document.querySelector('#rows tbody'); const tbody = document.querySelector('#rows tbody');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const rowsPanel = document.getElementById('rowsPanel');
const emptySelection = document.getElementById('emptySelection');
const openResultBtn = document.getElementById('openResult');
const copyResultBtn = document.getElementById('copyResult');
function parseIterationName(filename) { function parseIterationName(filename) {
const base = filename.replace(/\.html?$/i, ''); const base = filename.replace(/\.html?$/i, '');
@@ -63,65 +75,101 @@ function parseIterationName(filename) {
return base; return base;
} }
function addRow() { function escapeHtml(s){
const id = 'r' + (nextId++); return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const tr = document.createElement('tr'); }
tr.dataset.id = id; function addFiles(files) {
tr.innerHTML = ` + "`" + ` const existing = new Set(rows.map(r => r.path));
<td> const before = rows.length;
<div class="file-cell"> (files || []).forEach(f => {
<button class="pick secondary">Choose...</button> if (existing.has(f.path)) return;
<span class="file-name" data-name>(no file)</span> rows.push({ id:'r' + (nextId++), path:f.path, name:f.name, iteration:parseIterationName(f.name), error:'' });
</div> existing.add(f.path);
<input type="hidden" data-path /> });
<div class="row-error" data-error></div> const added = rows.length - before;
</td> if (added > 1) {
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td> const batch = rows.slice(rows.length - added);
<td><button class="remove-btn" title="Remove">×</button></td> const first = batch[0].iteration;
` + "`" + `; if (first && batch.every(r => r.iteration === first)) {
tr.querySelector('.pick').addEventListener('click', async () => { batch.forEach((r, i) => r.iteration = String(i + 1).padStart(2, '0') + '_' + first);
const res = await fetch('/api/plec/pick', { method: 'POST' });
const j = await res.json();
if (j.files && j.files.length) {
const affected = [];
j.files.forEach(function(f, i) {
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
target.querySelector('[data-path]').value = f.path;
target.querySelector('[data-name]').textContent = f.name;
const iter = target.querySelector('[data-iter]');
if (!iter.value) iter.value = parseIterationName(f.name);
affected.push(target);
});
if (affected.length > 1) {
const iters = affected.map(r => r.querySelector('[data-iter]').value);
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
affected.forEach((r, i) => {
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
});
}
}
} }
}
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
renderRows();
}
function renderRows() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
if (!rows.length) { tbody.innerHTML = ''; return; }
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
page = Math.min(page, maxPage);
const start = page * PAGE_SIZE;
const pageRows = rows.slice(start, start + PAGE_SIZE);
tbody.innerHTML = pageRows.map((row, offset) => {
const i = start + offset;
return '<tr data-id="' + row.id + '">' +
'<td><span class="mono wrap">' + escapeHtml(row.name) + '</span><div class="row-error">' + escapeHtml(row.error || '') + '</div></td>' +
'<td><input type="text" data-iter data-index="' + i + '" value="' + escapeHtml(row.iteration) + '" /></td>' +
'<td><button class="remove-btn danger" data-remove="' + i + '" title="Remove">×</button></td>' +
'</tr>';
}).join('');
tbody.querySelectorAll('[data-iter]').forEach(input => {
input.addEventListener('input', () => rows[Number(input.dataset.index)].iteration = input.value);
}); });
tr.querySelector('.remove-btn').addEventListener('click', () => { tbody.querySelectorAll('[data-remove]').forEach(btn => {
tr.remove(); btn.addEventListener('click', () => { rows.splice(Number(btn.dataset.remove), 1); renderRows(); });
if (!tbody.children.length) addRow();
}); });
tbody.appendChild(tr); if (rows.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML = '<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
rowsPanel.after(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
}
}
function setPreview(url) {
previewUrl = url || '';
openResultBtn.disabled = !previewUrl;
copyResultBtn.disabled = !previewUrl;
} }
document.getElementById('addRow').addEventListener('click', addRow); document.getElementById('pickFolder').addEventListener('click', async () => {
const res = await fetch('/api/plec/pickFolder', { method: 'POST' });
const j = await res.json();
addFiles(j.files || []);
});
document.getElementById('pickFiles').addEventListener('click', async () => {
const res = await fetch('/api/plec/pick', { method: 'POST' });
const j = await res.json();
addFiles(j.files || []);
});
document.getElementById('clear').addEventListener('click', () => {
rows = [];
page = 0;
setPreview('');
statusEl.textContent = '';
renderRows();
});
openResultBtn.addEventListener('click', () => {
if (previewUrl) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: previewUrl }) });
});
copyResultBtn.addEventListener('click', () => {
if (previewUrl) fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: previewUrl }) });
});
document.getElementById('upload').addEventListener('click', async () => { document.getElementById('upload').addEventListener('click', async () => {
document.querySelectorAll('[data-error]').forEach(el => el.textContent = ''); rows.forEach(row => row.error = '');
renderRows();
statusEl.textContent = ''; statusEl.textContent = '';
resultsEl.innerHTML = ''; setPreview('');
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
id: tr.dataset.id,
path: tr.querySelector('[data-path]').value,
iteration: tr.querySelector('[data-iter]').value,
})).filter(r => r.path || r.iteration);
if (rows.length === 0) { if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>'; statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return; return;
} }
statusEl.textContent = 'Uploading...'; statusEl.textContent = 'Uploading...';
@@ -133,9 +181,10 @@ document.getElementById('upload').addEventListener('click', async () => {
const j = await res.json(); const j = await res.json();
if (j.rowErrors) { if (j.rowErrors) {
for (const re of j.rowErrors) { for (const re of j.rowErrors) {
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]'); const row = rows.find(r => r.id === re.id);
if (tr) tr.querySelector('[data-error]').textContent = re.message; if (row) row.error = re.message;
} }
renderRows();
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>'; statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
return; return;
} }
@@ -144,40 +193,10 @@ document.getElementById('upload').addEventListener('click', async () => {
return; return;
} }
statusEl.innerHTML = '<span class="ok">Upload complete.</span>'; statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
const wrap = document.createElement('div'); setPreview(j.preview);
wrap.className = 'result';
if (j.preview) {
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
const actions = document.createElement('div');
actions.className = 'result-actions';
const copy = document.createElement('button');
copy.textContent = 'Copy';
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
const open = document.createElement('button');
open.textContent = 'Open';
open.className = 'secondary';
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
const showRaw = document.createElement('button');
showRaw.textContent = 'Show server response';
showRaw.className = 'secondary';
showRaw.onclick = () => {
const pre = document.createElement('pre');
pre.style.marginTop = '8px';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = j.rawText;
wrap.appendChild(pre);
showRaw.disabled = true;
};
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
wrap.appendChild(actions);
} else {
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'&lt;') + '</pre>';
}
resultsEl.innerHTML = '';
resultsEl.appendChild(wrap);
}); });
addRow(); renderRows();
</script> </script>
` `
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -204,6 +223,23 @@ func PlecPick(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"files": files}) writeJSON(w, map[string]any{"files": files})
} }
func PlecPickFolder(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
dir := PickFolder("Select folder", cfg.LastPickDir)
if dir == "" {
writeJSON(w, map[string]any{"files": []any{}})
return
}
cfg.LastPickDir = dir
_ = SaveConfig(cfg)
paths := collectHTMLFiles(dir)
files := make([]map[string]string, 0, len(paths))
for _, p := range paths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files})
}
type plecRow struct { type plecRow struct {
ID string `json:"id"` ID string `json:"id"`
Path string `json:"path"` Path string `json:"path"`