drop zone, and remove beta tag from mraid checker

This commit is contained in:
2026-05-29 13:20:33 +08:00
parent a1d3fde774
commit 5625d94106
21 changed files with 632 additions and 48 deletions

View File

@@ -2,8 +2,13 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const SharedCSS = `
@@ -107,6 +112,9 @@ const SharedCSS = `
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; }
@@ -118,6 +126,28 @@ const SharedCSS = `
.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; }
@@ -131,6 +161,160 @@ const SharedCSS = `
.hint { font-size: 12px; opacity: 0.7; }
`
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
@@ -143,8 +327,8 @@ var navItems = []navItem{
{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: "/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: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
}
@@ -207,6 +391,7 @@ func Page(activePath, title, body string) string {
<meta charset="UTF-8" />
<title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style>
<script>` + SharedDropZoneScript + `</script>
</head><body>
<div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
<div class="content">` + body + `</div>
@@ -242,6 +427,153 @@ func boolAttr(v bool) string {
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"`