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

View File

@@ -17,42 +17,54 @@ import (
func PlecPage(w http.ResponseWriter, r *http.Request) {
body := `
<h2>PLEC Upload</h2>
<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>
<header class="tool-header">
<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;">
<thead><tr>
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th>
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th>
<th></th>
</tr></thead>
<tbody></tbody>
</table>
<div style="display:flex;gap:8px;">
<button id="addRow" class="secondary">+ Add Row</button>
<button id="upload">Upload</button>
</div>
<div id="status" style="margin-top:12px;min-height:20px;"></div>
<div id="results"></div>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-body">
<div class="control-group">
<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="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
<table id="rows" class="data-table">
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
<tbody></tbody>
</table>
</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>
#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; }
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
.result a { color:#9cdcfe; }
.result-actions { margin-top:8px; display:flex; gap:8px; }
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
#rows input[type=text] { width: 100%; }
</style>
<script>
const PAGE_SIZE = 5;
let nextId = 1;
let rows = [];
let page = 0;
let previewUrl = '';
const tbody = document.querySelector('#rows tbody');
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) {
const base = filename.replace(/\.html?$/i, '');
@@ -63,65 +75,101 @@ function parseIterationName(filename) {
return base;
}
function addRow() {
const id = 'r' + (nextId++);
const tr = document.createElement('tr');
tr.dataset.id = id;
tr.innerHTML = ` + "`" + `
<td>
<div class="file-cell">
<button class="pick secondary">Choose...</button>
<span class="file-name" data-name>(no file)</span>
</div>
<input type="hidden" data-path />
<div class="row-error" data-error></div>
</td>
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td>
<td><button class="remove-btn" title="Remove">×</button></td>
` + "`" + `;
tr.querySelector('.pick').addEventListener('click', async () => {
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];
});
}
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function addFiles(files) {
const existing = new Set(rows.map(r => r.path));
const before = rows.length;
(files || []).forEach(f => {
if (existing.has(f.path)) return;
rows.push({ id:'r' + (nextId++), path:f.path, name:f.name, iteration:parseIterationName(f.name), error:'' });
existing.add(f.path);
});
const added = rows.length - before;
if (added > 1) {
const batch = rows.slice(rows.length - added);
const first = batch[0].iteration;
if (first && batch.every(r => r.iteration === first)) {
batch.forEach((r, i) => r.iteration = String(i + 1).padStart(2, '0') + '_' + first);
}
}
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', () => {
tr.remove();
if (!tbody.children.length) addRow();
tbody.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => { rows.splice(Number(btn.dataset.remove), 1); renderRows(); });
});
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.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
rows.forEach(row => row.error = '');
renderRows();
statusEl.textContent = '';
resultsEl.innerHTML = '';
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);
setPreview('');
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;
}
statusEl.textContent = 'Uploading...';
@@ -133,9 +181,10 @@ document.getElementById('upload').addEventListener('click', async () => {
const j = await res.json();
if (j.rowErrors) {
for (const re of j.rowErrors) {
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]');
if (tr) tr.querySelector('[data-error]').textContent = re.message;
const row = rows.find(r => r.id === re.id);
if (row) row.error = re.message;
}
renderRows();
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
return;
}
@@ -144,40 +193,10 @@ document.getElementById('upload').addEventListener('click', async () => {
return;
}
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
const wrap = document.createElement('div');
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);
setPreview(j.preview);
});
addRow();
renderRows();
</script>
`
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})
}
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 {
ID string `json:"id"`
Path string `json:"path"`