Files
2026-06-22 15:10:22 +08:00

1078 lines
40 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const SharedCSS = `
: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 {
min-height: 100vh;
display: grid;
grid-template-columns: var(--sidebar-width, 236px) minmax(0, 1fr);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #1e1e1e; color: #ddd;
margin: 0; padding: 0;
}
body.sidebar-collapsed { --sidebar-width: 56px; }
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 8px;
background: #252526;
border-right: 1px solid #333;
font-size: 13px;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 2px 6px;
border-bottom: 1px solid #333;
}
.sidebar-title {
min-width: 0;
flex: 1;
color: #ddd;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.45px;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-toggle {
width: 32px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
background: transparent;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
font-size: 16px;
line-height: 1;
}
.sidebar-toggle:hover { background: #2a2d2e; }
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow: auto;
}
.sidebar .nav-link {
width: 100%;
min-height: 34px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 8px;
background: transparent;
color: #ddd;
border: 0;
padding: 4px 8px 4px 4px;
border-radius: 4px;
cursor: pointer;
font: inherit;
text-align: left;
}
.sidebar .nav-link:hover { background: #2a2d2e; }
.sidebar .nav-link.active { background: #094771; color: #fff; }
.sidebar .nav-link.dragging { opacity: 0.55; }
.nav-icon {
width: 28px;
height: 26px;
display: grid;
place-items: center;
border-radius: 4px;
background: rgba(128,128,128,0.12);
color: #ddd;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.nav-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
.app-shell {
min-width: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
.app-footer {
width: 100%;
max-width: 1100px;
margin: auto auto 0;
padding: 8px 18px 14px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
color: #a7a7a7;
font-size: 10px;
border-top: 1px solid #333;
}
.beta-tools-toggle {
justify-self: start;
padding: 0;
background: transparent;
color: #a7a7a7;
border: 0;
cursor: pointer;
font: inherit;
opacity: 0.72;
text-align: left;
}
.beta-tools-toggle:hover { background: transparent; opacity: 1; text-decoration: underline; }
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
.app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; }
.app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; }
.app-footer-version-button { justify-self: center; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.75; text-align: center; white-space: nowrap; }
.app-footer-version-button:hover { background: transparent; opacity: 1; text-decoration: underline; }
.toast-box {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 50;
max-width: min(420px, calc(100vw - 36px));
padding: 10px 12px;
display: none;
align-items: center;
gap: 10px;
color: #ddd;
background: #252526;
border: 1px solid #444;
border-left: 3px solid #007fd4;
border-radius: 5px;
box-shadow: 0 10px 28px rgba(0,0,0,0.35);
font-size: 12px;
line-height: 1.35;
}
.toast-box.is-visible { display: flex; }
.toast-box.is-update { border-left-color: #ffd580; }
.toast-box.is-current { border-left-color: #89d185; }
.toast-box.is-error { border-left-color: #f48771; }
.toast-message { min-width: 0; flex: 1; overflow-wrap: anywhere; }
.toast-action {
flex: 0 0 auto;
padding: 4px 8px;
background: #0e639c;
color: #fff;
border: 0;
border-radius: 2px;
font-size: 12px;
}
.toast-close {
flex: 0 0 auto;
width: 22px;
height: 22px;
padding: 0;
display: grid;
place-items: center;
background: transparent;
color: #a7a7a7;
border: 0;
font-size: 16px;
line-height: 1;
}
.toast-close:hover { background: rgba(255,255,255,0.08); color: #fff; }
body.sidebar-collapsed .sidebar { align-items: stretch; }
body.sidebar-collapsed .sidebar-title,
body.sidebar-collapsed .nav-text,
body.sidebar-collapsed .beta-pill { display: none; }
body.sidebar-collapsed .sidebar .nav-link {
grid-template-columns: 28px;
justify-content: center;
padding: 4px;
}
body.sidebar-collapsed .sidebar-header { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .sidebar-toggle { width: 38px; }
@media (max-width: 760px) {
body { grid-template-columns: 1fr; }
.sidebar {
position: static;
height: auto;
max-height: 48vh;
border-right: 0;
border-bottom: 1px solid #333;
}
body.sidebar-collapsed .sidebar { max-height: 50px; }
}
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 {
box-sizing: border-box; padding: 6px 8px;
background: #3c3c3c; color: #ddd;
border: 1px solid #3c3c3c; border-radius: 2px;
font-family: inherit; font-size: 13px;
}
input[type=text]:focus, input[type=date]:focus, select:focus, textarea:focus {
outline: 1px solid #007fd4; border-color: #007fd4;
}
button {
background: #0e639c; color: #fff;
border: none; padding: 6px 14px; border-radius: 2px; cursor: pointer; font-size: 13px;
}
button:hover { background: #1177bb; }
button.secondary { background: #3a3d41; color: #ddd; }
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; }
.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.is-busy { display: flex; align-items: center; gap: 8px; }
.status-panel.is-busy::before { content: ""; width: 12px; height: 12px; flex: 0 0 auto; border: 2px solid rgba(167,167,167,0.35); border-top-color: #ddd; border-radius: 50%; animation: status-spin 800ms linear infinite; }
@keyframes status-spin { to { transform: rotate(360deg); } }
.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; }
.drop-zone {
margin-top: 8px;
min-height: 44px;
display: grid;
justify-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px dashed #444;
border-radius: var(--tool-radius);
color: #a7a7a7;
background: rgba(128,128,128,0.08);
font-size: 12px;
text-align: center;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
}
.drop-zone .file-row { justify-content: center; }
.drop-zone-text { color: #a7a7a7; font-size: 12px; }
.drop-zone.is-dragover {
border-color: #007fd4;
color: #ddd;
background: rgba(0,127,212,0.12);
}
.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; }
.ok { color: #89d185; }
.hint { font-size: 12px; opacity: 0.7; }
.settings-modal { position:fixed; inset:0; z-index:200; display:none; align-items:center; justify-content:center; padding:18px; background:rgba(0,0,0,.48); }
.settings-modal.active { display:flex; }
.settings-modal-dialog { width:min(840px,94vw); min-height:min(560px,80vh); max-height:calc(100vh - 36px); display:flex; flex-direction:column; border:1px solid var(--tool-border); border-radius:var(--tool-radius); background:#1e1e1e; box-shadow:0 16px 48px rgba(0,0,0,.45); overflow:hidden; }
.settings-modal-dialog > .panel-header { flex-shrink:0; }
.settings-modal-body { overflow-y:auto; padding:12px; flex:1 1 0; min-height:0; }
.settings-modal-body .tool-panel { margin-bottom:var(--tool-gap-lg); scroll-margin-top:8px; }
@keyframes settings-flash { 0%,100%{box-shadow:none} 25%,75%{box-shadow:0 0 0 2px #007fd4} }
.settings-highlight { animation:settings-flash 1.2s ease forwards; }
.settings-grid { display:flex; flex-direction:column; gap:12px; }
.settings-label { display:flex; flex-direction:column; gap:5px; color:#a7a7a7; font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; max-width:100%; }
.settings-label input[type=text] { width:100%; }
.settings-label.settings-checkbox { flex-direction:row; align-items:center; gap:8px; text-transform:none; letter-spacing:0; font-size:13px; font-weight:400; color:#ddd; cursor:pointer; }
.settings-hint { color:#a7a7a7; font-size:11px; font-weight:400; letter-spacing:0; text-transform:none; }
.settings-save-status { font-size:12px; color:#89d185; min-height:20px; }
.settings-save-status.err { color:#f48771; }
.advanced-details { margin-bottom:var(--tool-gap-lg); }
.advanced-summary { display:inline-flex; align-items:center; gap:6px; margin-bottom:10px; padding:5px 10px; color:#a7a7a7; font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; border:1px solid var(--tool-border); border-radius:var(--tool-radius); background:rgba(128,128,128,.08); cursor:pointer; user-select:none; list-style:none; }
.advanced-summary::-webkit-details-marker { display:none; }
.advanced-summary::before { content:"▶"; font-size:9px; transition:transform 120ms ease; }
details[open] > .advanced-summary::before { transform:rotate(90deg); }
.advanced-summary:hover { color:#ddd; border-color:#555; }
`
const SharedDropZoneScript = `
function extractDroppedPaths(dataTransfer) {
const paths = [];
const add = (value) => {
if (!value) return;
const trimmed = String(value).trim();
if (trimmed && !paths.includes(trimmed)) paths.push(trimmed);
};
const addPathText = (text) => {
const value = String(text || '');
if (!value) return;
const uriMatches = value.match(/file:\/\/(?:\/[A-Za-z]:)?[^\\r\\n\\0]+/gi) || [];
uriMatches.forEach(add);
const quotedMatches = value.match(/"([^"]+)"|'([^']+)'/g) || [];
quotedMatches.forEach(match => add(match.replace(/^["']|["']$/g, '')));
value.split(/[\r\n\0]+/).forEach(line => {
let rest = line.trim();
if (!rest || rest.startsWith('#')) return;
while (rest.length) {
const driveMatches = [...rest.matchAll(/[A-Za-z]:\\/g)].map(m => m.index).filter(i => typeof i === 'number');
if (driveMatches.length <= 1) {
add(rest);
break;
}
const next = driveMatches[1];
add(rest.slice(0, next).trim());
rest = rest.slice(next).trim();
}
});
};
const addLines = (text) => {
addPathText(text);
};
try { addLines(dataTransfer.getData('text/uri-list')); } catch {}
try { addLines(dataTransfer.getData('text/plain')); } catch {}
for (const file of Array.from(dataTransfer.files || [])) {
add(file.path);
}
return paths;
}
async function extractDroppedHtmlFiles(dataTransfer) {
const files = [];
const seen = new Set();
const droppedFiles = Array.from(dataTransfer.files || []);
const entries = [];
const handlePromises = [];
for (const item of Array.from(dataTransfer.items || [])) {
if (typeof item.getAsFileSystemHandle === 'function') {
handlePromises.push(item.getAsFileSystemHandle().catch(() => null));
}
let entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (!entry && typeof item.getAsEntry === 'function') entry = item.getAsEntry();
if (entry) entries.push(entry);
}
const addFile = async (file, name) => {
const fileName = name || file.name || 'dropped.html';
if (!/\.html?$/i.test(fileName)) return;
const key = fileName + ':' + file.size + ':' + file.lastModified;
if (seen.has(key)) return;
seen.add(key);
files.push({ name: fileName, content: await file.text() });
};
const readEntry = async (entry, prefix) => {
if (!entry) return;
if (entry.isFile) {
await new Promise((resolve) => {
entry.file(async (file) => {
await addFile(file, (prefix || '') + file.name);
resolve();
}, () => resolve());
});
return;
}
if (entry.isDirectory) {
const reader = entry.createReader();
while (true) {
const entries = await new Promise(resolve => reader.readEntries(resolve, () => resolve([])));
if (!entries.length) break;
for (const child of entries) await readEntry(child, (prefix || '') + entry.name + '/');
}
}
};
const readHandle = async (handle, prefix) => {
if (!handle) return;
if (handle.kind === 'file') {
try {
const file = await handle.getFile();
await addFile(file, (prefix || '') + file.name);
} catch {}
return;
}
if (handle.kind === 'directory') {
try {
for await (const child of handle.values()) {
await readHandle(child, (prefix || '') + handle.name + '/');
}
} catch {}
}
};
for (const file of droppedFiles) await addFile(file, file.name);
for (const entry of entries) await readEntry(entry, '');
for (const handle of await Promise.all(handlePromises)) await readHandle(handle, '');
return files;
}
function setupDropZone(id, statusElement, onResolved) {
const zone = document.getElementById(id);
if (!zone) return;
const fallback = 'This view could not read dropped file paths. Use Select File(s) or Select Folder instead.';
const setMessage = (text) => {
if (statusElement) statusElement.textContent = text || '';
};
['dragenter', 'dragover'].forEach(type => {
zone.addEventListener(type, (event) => {
if (event.target && event.target.closest && event.target.closest('button, input')) return;
event.preventDefault();
event.stopPropagation();
zone.classList.add('is-dragover');
});
});
['dragleave', 'dragend'].forEach(type => {
zone.addEventListener(type, (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragover');
});
});
zone.addEventListener('drop', async (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragover');
const paths = extractDroppedPaths(event.dataTransfer);
const files = await extractDroppedHtmlFiles(event.dataTransfer);
if (!paths.length && !files.length) {
setMessage(fallback);
return;
}
setMessage('');
try {
const r = await fetch('/api/drop/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths, files }),
});
const j = await r.json();
if (typeof onResolved === 'function') onResolved(j);
} catch (e) {
setMessage('Could not read dropped items: ' + (e.message || e));
}
});
}
`
type navItem struct {
Path string
Label string
Description string
Beta bool
}
var navItems = []navItem{
{Path: "/", Label: "Home"},
{Path: "/project-init", Label: "Initialize Project", Description: "Download AGENTS.md and .gitignore templates from Google Drive"},
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
{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: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
{Path: "/mintegral", Label: "Mintegral Checker", Description: "Validate Mintegral playable ZIPs against PlayTurbo checks"},
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants"},
{Path: "/device-simulator", Label: "Device Simulator", Description: "Preview HTML playables inside mobile device frames"},
}
func visibleNavItems(betaToolsEnabled bool) []navItem {
out := make([]navItem, 0, len(navItems))
for _, n := range navItems {
if n.Beta && !betaToolsEnabled {
continue
}
out = append(out, n)
}
return sortNavItemsForDisplay(out)
}
func sortNavItemsForDisplay(items []navItem) []navItem {
out := append([]navItem(nil), items...)
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[i].Beta && !out[j].Beta {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
func betaToolCount() int {
count := 0
for _, n := range navItems {
if n.Beta {
count++
}
}
return count
}
func navInitials(label string) string {
switch label {
case "Initialize Project":
return "IP"
case "Base64 Scanner":
return "B64"
case "AppLovin Playable Preview":
return "APP"
case "Mintegral Checker":
return "MiC"
case "Device Simulator":
return "DS"
}
words := strings.Fields(label)
if len(words) == 0 {
return "?"
}
if len(words) == 1 {
r := []rune(words[0])
if len(r) == 0 {
return "?"
}
return strings.ToUpper(string(r[0]))
}
first := []rune(words[0])
second := []rune(words[1])
if len(first) == 0 || len(second) == 0 {
return strings.ToUpper(string([]rune(label)[0]))
}
return strings.ToUpper(string(first[0]) + string(second[0]))
}
func Page(activePath, title, body string) string {
cfg := LoadConfig()
betaToolsEnabled := cfg.BetaToolsEnabled
randomizeUA := cfg.Applovin.RandomizeUserAgent == nil || *cfg.Applovin.RandomizeUserAgent
var tabs strings.Builder
for _, n := range visibleNavItems(betaToolsEnabled) {
cls := ""
if n.Path == activePath {
cls = " active"
}
label := n.Label
if n.Beta {
label += `<span class="beta-pill">Beta</span>`
}
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `" draggable="` + boolAttr(n.Path != "/") + `" title="` + n.Label + `"><span class="nav-icon">` + navInitials(n.Label) + `</span><span class="nav-text">` + label + `</span></button>`)
}
betaButtonLabel := "Show beta"
if betaToolsEnabled {
betaButtonLabel = "Hide beta"
}
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8" />
<title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style>
<script>` + SharedDropZoneScript + `</script>
</head><body>
<aside class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">HPL Toolbox</span>
<button id="sidebarToggle" class="sidebar-toggle" title="Toggle sidebar" aria-label="Toggle sidebar">☰</button>
</div>
<nav class="sidebar-nav">` + tabs.String() + `</nav>
</aside>
<div class="app-shell">
<div class="content">` + body + `</div>
<div class="app-footer">
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
<button id="updateCheck" type="button" class="app-footer-version-button" title="Check for updates">` + AppVersion + ` - JJGC 00784</button>
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
</div>
</div>
<div id="toastBox" class="toast-box" role="status" aria-live="polite">
<span id="toastMessage" class="toast-message"></span>
<button id="toastAction" type="button" class="toast-action is-hidden">Open Repository</button>
<button id="toastClose" type="button" class="toast-close" aria-label="Close">x</button>
</div>
<script>
if (localStorage.getItem('hplSidebarCollapsed') === 'true') {
document.body.classList.add('sidebar-collapsed');
}
document.getElementById('sidebarToggle').addEventListener('click', () => {
const collapsed = document.body.classList.toggle('sidebar-collapsed');
localStorage.setItem('hplSidebarCollapsed', collapsed ? 'true' : 'false');
});
document.querySelectorAll('[data-path]').forEach((element) => {
element.addEventListener('click', () => {
if (element.dataset.dragging === 'true') {
element.dataset.dragging = 'false';
return;
}
const path = element.dataset.path;
if (path) window.location.href = path;
});
});
const TOOL_ORDER_KEY = 'hplToolbox.toolOrder.v1';
function getToolOrder() {
try {
const parsed = JSON.parse(localStorage.getItem(TOOL_ORDER_KEY) || '[]');
return Array.isArray(parsed) ? parsed.filter(path => typeof path === 'string') : [];
} catch {
return [];
}
}
function setToolOrder(paths) {
localStorage.setItem(TOOL_ORDER_KEY, JSON.stringify(paths));
}
function applyStoredToolOrder(container, selector) {
if (!container) return;
const order = getToolOrder();
if (!order.length) return;
const elements = Array.from(container.querySelectorAll(selector));
const byPath = new Map(elements.map(el => [el.dataset.path, el]));
const home = byPath.get('/');
const ordered = order.map(path => byPath.get(path)).filter(Boolean);
const orderedSet = new Set(ordered);
const remaining = elements.filter(el => el !== home && !orderedSet.has(el));
if (home) container.appendChild(home);
ordered.concat(remaining).forEach(el => container.appendChild(el));
}
function setupToolReorder(container, selector) {
if (!container) return;
applyStoredToolOrder(container, selector);
let dragged = null;
container.querySelectorAll(selector).forEach(element => {
if (element.dataset.path === '/') return;
element.addEventListener('dragstart', event => {
dragged = element;
element.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', element.dataset.path || '');
});
element.addEventListener('dragend', () => {
element.classList.remove('dragging');
element.dataset.dragging = 'true';
dragged = null;
const paths = Array.from(container.querySelectorAll(selector))
.map(el => el.dataset.path)
.filter(path => path && path !== '/');
setToolOrder(paths);
setTimeout(() => { element.dataset.dragging = 'false'; }, 0);
});
element.addEventListener('dragover', event => {
event.preventDefault();
if (!dragged || dragged === element || element.dataset.path === '/') return;
const rect = element.getBoundingClientRect();
const after = event.clientY > rect.top + rect.height / 2;
container.insertBefore(dragged, after ? element.nextSibling : element);
});
});
}
setupToolReorder(document.querySelector('.sidebar-nav'), '.nav-link');
setupToolReorder(document.querySelector('.home-tools'), '.home-tool');
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
const enabled = event.currentTarget.dataset.enabled === 'true';
await fetch('/api/betaTools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !enabled }),
});
window.location.reload();
});
const toastBox = document.getElementById('toastBox');
const toastMessage = document.getElementById('toastMessage');
const toastAction = document.getElementById('toastAction');
const toastClose = document.getElementById('toastClose');
let toastTimer = null;
let updateRepositoryUrl = '';
function showToast(message, status, repositoryUrl) {
window.clearTimeout(toastTimer);
updateRepositoryUrl = repositoryUrl || '';
toastMessage.textContent = message || '';
toastAction.classList.toggle('is-hidden', !updateRepositoryUrl);
toastBox.className = 'toast-box is-visible is-' + (status || 'current');
toastTimer = window.setTimeout(() => {
toastBox.classList.remove('is-visible');
}, updateRepositoryUrl ? 9000 : 5000);
}
async function checkForUpdates(showCurrent) {
try {
const response = await fetch('/api/update/check');
const result = await response.json();
if (result.status === 'current' && !showCurrent) return;
showToast(result.message || 'HPL Toolbox update check finished.', result.status, result.repositoryUrl);
} catch (error) {
showToast('HPL Toolbox update check failed: ' + (error.message || error), 'error');
}
}
document.getElementById('updateCheck').addEventListener('click', () => {
checkForUpdates(true);
});
toastClose.addEventListener('click', () => {
window.clearTimeout(toastTimer);
toastBox.classList.remove('is-visible');
});
toastAction.addEventListener('click', async () => {
if (!updateRepositoryUrl) return;
await fetch('/api/open', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: updateRepositoryUrl }),
});
});
if (sessionStorage.getItem('hplToolboxUpdateChecked') !== 'true') {
sessionStorage.setItem('hplToolboxUpdateChecked', 'true');
checkForUpdates(true);
}
function openSettingsModal(sectionId) {
document.getElementById('settingsModal').classList.add('active');
if (sectionId) {
const el = document.getElementById('settings-section-' + sectionId);
if (el) {
const details = el.closest('details');
if (details) details.open = true;
setTimeout(function() {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
el.classList.add('settings-highlight');
setTimeout(function() { el.classList.remove('settings-highlight'); }, 1300);
}, 50);
}
}
}
function closeSettingsModal() {
document.getElementById('settingsModal').classList.remove('active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.getElementById('settingsModal').classList.contains('active')) closeSettingsModal();
});
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('settingsModalClose').addEventListener('click', closeSettingsModal);
document.getElementById('settingsModal').addEventListener('click', function(e) {
if (e.target === this) closeSettingsModal();
});
document.getElementById('settingsModalForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.currentTarget;
const status = document.getElementById('settingsModalStatus');
const getVal = (name) => (form.querySelector('[name="' + name + '"]') || {}).value || '';
const getChecked = (name) => !!(form.querySelector('[name="' + name + '"]') || {}).checked;
const payload = {
plec: { uploadUrl: getVal('plec.uploadUrl'), originUrl: getVal('plec.originUrl'), previewBase: getVal('plec.previewBase'), skipExistsCheck: getChecked('plec.skipExistsCheck') },
applovin: { cookie: getVal('applovin.cookie'), delayMs: parseInt(getVal('applovin.delayMs'), 10) || 0, randomizeUserAgent: getChecked('applovin.randomizeUserAgent') },
sendToMobile: { port: parseInt(getVal('sendToMobile.port'), 10) || 0 },
projectInitManifestUrl: getVal('projectInitManifestUrl'),
deviceSimulator: { devicesUrl: getVal('deviceSimulator.devicesUrl'), languagesUrl: getVal('deviceSimulator.languagesUrl') },
};
try {
const r = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const j = await r.json();
if (j.ok) {
status.textContent = 'Saved.';
status.className = 'settings-save-status';
setTimeout(closeSettingsModal, 900);
} else {
status.textContent = 'Error: ' + (j.error || 'unknown');
status.className = 'settings-save-status err';
}
} catch (err) {
status.textContent = 'Error: ' + (err.message || err);
status.className = 'settings-save-status err';
}
setTimeout(function() { status.textContent = ''; status.className = 'settings-save-status'; }, 4000);
});
});
</script>
<div id="settingsModal" class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="settings-modal-dialog">
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<h3 id="settingsModalTitle" class="panel-title">Settings</h3>
<button id="settingsModalClose" type="button" class="icon-btn" style="margin-left:auto;" title="Close" aria-label="Close">&times;</button>
</div>
<div class="settings-modal-body">
<form id="settingsModalForm">
<section id="settings-section-device-simulator" class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Device Simulator</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">Devices JSON URL
<input type="text" name="deviceSimulator.devicesUrl" value="` + htmlEsc(cfg.DeviceSimulator.DevicesURL) + `" placeholder="Google Drive share link or direct JSON URL" />
<span class="settings-hint">Remote devices.json — fetched on open, falls back to built-in list if empty or unreachable.</span>
</label>
<label class="settings-label">Languages JSON URL
<input type="text" name="deviceSimulator.languagesUrl" value="` + htmlEsc(cfg.DeviceSimulator.LanguagesURL) + `" placeholder="Google Drive share link or direct JSON URL" />
<span class="settings-hint">Remote languages.json — fetched on open, falls back to built-in list if empty or unreachable.</span>
</label>
</div>
</div>
</section>
<section id="settings-section-initialize-project" class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Initialize Project</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">Manifest URL
<input type="text" name="projectInitManifestUrl" value="` + htmlEsc(cfg.ProjectInitManifestURL) + `" placeholder="Google Drive share link or direct JSON URL" />
</label>
</div>
</div>
</section>
<details class="advanced-details">
<summary class="advanced-summary">Advanced</summary>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">PLEC Upload</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">Upload URL
<input type="text" name="plec.uploadUrl" value="` + htmlEsc(cfg.Plec.UploadUrl) + `" />
</label>
<label class="settings-label">Origin URL
<input type="text" name="plec.originUrl" value="` + htmlEsc(cfg.Plec.OriginUrl) + `" />
</label>
<label class="settings-label">Preview Base URL
<input type="text" name="plec.previewBase" value="` + htmlEsc(cfg.Plec.PreviewBase) + `" />
</label>
<label class="settings-label settings-checkbox">
<input type="checkbox" name="plec.skipExistsCheck" ` + checkedAttr(cfg.Plec.SkipExistsCheck) + ` />
Skip duplicate filename check
</label>
</div>
</div>
</section>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">AppLovin Playable Preview</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">__Host-APPLOVINID Cookie
<input type="text" name="applovin.cookie" value="` + htmlEsc(cfg.Applovin.Cookie) + `" placeholder="Log in at p.applov.in and copy from DevTools" />
</label>
<label class="settings-label">Upload delay (ms)
<input type="text" name="applovin.delayMs" value="` + intStr(cfg.Applovin.DelayMs) + `" inputmode="numeric" />
</label>
<label class="settings-label settings-checkbox">
<input type="checkbox" name="applovin.randomizeUserAgent" ` + checkedAttr(randomizeUA) + ` />
Randomize User-Agent on every request
</label>
</div>
</div>
</section>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Send To Mobile</h3></div>
<div class="panel-body">
<div class="settings-grid">
<label class="settings-label">LAN server port
<input type="text" name="sendToMobile.port" value="` + intStr(cfg.SendToMobile.Port) + `" inputmode="numeric" />
<span class="settings-hint">0 = OS-assigned random port</span>
</label>
</div>
</div>
</section>
</details>
<div class="action-row" style="margin-top:4px;">
<button type="submit">Save Settings</button>
<span id="settingsModalStatus" class="settings-save-status"></span>
</div>
</form>
</div>
</div>
</div>
</body></html>`
}
func boolAttr(v bool) string {
if v {
return "true"
}
return "false"
}
func DropResolveEndpoint(w http.ResponseWriter, r *http.Request) {
type droppedFile struct {
Name string `json:"name"`
Content string `json:"content"`
}
var req struct {
Paths []string `json:"paths"`
Files []droppedFile `json:"files"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"files": []any{}, "paths": []string{}, "skipped": 0, "error": err.Error()})
return
}
seen := map[string]bool{}
var outPaths []string
skipped := 0
addFile := func(p string) {
if !isHTMLPath(p) {
skipped++
return
}
key := strings.ToLower(filepath.Clean(p))
if seen[key] {
return
}
seen[key] = true
outPaths = append(outPaths, p)
}
for _, raw := range req.Paths {
p := normalizeDroppedPath(raw)
if p == "" {
skipped++
continue
}
info, err := os.Stat(p)
if err != nil {
skipped++
continue
}
if info.IsDir() {
for _, filePath := range collectHTMLFiles(p) {
addFile(filePath)
}
continue
}
if info.Mode().IsRegular() {
addFile(p)
continue
}
skipped++
}
if len(req.Files) > 0 {
cleanupStaleStandaloneDropDirs()
dir, err := os.MkdirTemp("", "hpltoolbox-drop-")
if err != nil {
skipped += len(req.Files)
} else {
for _, file := range req.Files {
if !isHTMLPath(file.Name) {
skipped++
continue
}
name := safeStandaloneDropName(file.Name)
target := filepath.Join(dir, name)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 1; fileExists(target); i++ {
target = filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, i, ext))
}
if err := os.WriteFile(target, []byte(file.Content), 0644); err != nil {
skipped++
continue
}
addFile(target)
}
}
}
files := make([]map[string]string, 0, len(outPaths))
for _, p := range outPaths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files, "paths": outPaths, "skipped": skipped})
}
func safeStandaloneDropName(name string) string {
base := filepath.Base(name)
base = strings.Map(func(r rune) rune {
switch r {
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
return '_'
default:
if r < 32 {
return '_'
}
return r
}
}, base)
base = strings.TrimSpace(base)
if base == "" {
return "dropped.html"
}
return base
}
func cleanupStaleStandaloneDropDirs() {
root := os.TempDir()
entries, err := os.ReadDir(root)
if err != nil {
return
}
cutoff := time.Now().Add(-24 * time.Hour)
for _, entry := range entries {
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "hpltoolbox-drop-") {
continue
}
dir := filepath.Join(root, entry.Name())
info, err := entry.Info()
if err == nil && info.ModTime().Before(cutoff) {
_ = os.RemoveAll(dir)
}
}
}
func normalizeDroppedPath(value string) string {
result := strings.Trim(strings.TrimSpace(value), `"'`)
if strings.HasPrefix(strings.ToLower(result), "file://") {
result = strings.TrimPrefix(result, "file://")
if decoded, err := url.PathUnescape(result); err == nil {
result = decoded
}
if len(result) >= 3 && result[0] == '/' && result[2] == ':' {
result = result[1:]
}
result = filepath.FromSlash(result)
}
return result
}
func isHTMLPath(p string) bool {
ext := strings.ToLower(filepath.Ext(p))
return ext == ".html" || ext == ".htm"
}
func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
cfg := LoadConfig()
cfg.BetaToolsEnabled = req.Enabled
if err := SaveConfig(cfg); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true})
}