diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..61bdaf5 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,131 @@ +# HPL Toolbox — Project Summary for AI + +## What this project is + +HPL Toolbox is an internal tool for a playable ad team. It ships as **two separate deliverables** from the same repo: + +1. **VS Code Extension** (`src/`) — a `.vsix` that adds tool panels as webview tabs inside VS Code. +2. **Standalone Windows App** (`standalone/`) — a Go + WebView2 desktop app that exposes the same tools through a local HTTP server rendered in a native window. It is a **real, complete, production app** — not a prototype or subset. + +Both deliverables implement the same set of tools. New features are typically added to both. + +--- + +## Directory structure + +``` +HPLToolbox-Extension/ +├── src/ +│ ├── extension.ts # Extension entry point, registers commands +│ ├── tools/ # One file per tool (VS Code webview panels) +│ │ ├── shared.ts # Shared utilities: singletonPanel, webview styles, clipboard/open handlers +│ │ ├── plecUpload.ts # PLEC Upload tool +│ │ ├── applovin.ts # AppLovin Playable Preview +│ │ ├── base64Scanner.ts # Base64 Scanner +│ │ ├── mraidChecker.ts # MRAID Checker +│ │ ├── mintegral.ts # Mintegral Checker +│ │ ├── sendToMobile.ts # Send To Mobile +│ │ ├── playworks.ts # Playworks Converter +│ │ ├── deviceSimulator.ts# Device Simulator +│ │ └── projectInit.ts # Initialize Project +│ └── tools.json # Tool launcher config (name, command, icon, description) +├── standalone/ # The Go standalone app — complete, production-grade +│ ├── main.go # Entry point: HTTP server + WebView2 window + route registration +│ ├── platform.go # AppConfig struct, defaultConfig(), LoadConfig/SaveConfig, OS helpers +│ ├── settings.go # /api/settings POST endpoint; settings modal HTML +│ ├── layout.go # Shared HTML layout, nav sidebar, CSS +│ ├── home.go # Home page / tool launcher +│ ├── plec.go # PLEC Upload: page HTML, pick endpoints, upload (legacy + WordPress) +│ ├── applovin.go # AppLovin upload +│ ├── base64.go # Base64 Scanner +│ ├── mraid.go # MRAID Checker +│ ├── mintegral.go # Mintegral Checker +│ ├── mobile.go # Send To Mobile +│ ├── playworks.go # Playworks Converter +│ ├── device_simulator.go # Device Simulator +│ ├── project_init.go # Initialize Project +│ ├── changelog.go # Changelog page +│ ├── json.go # newJSONStream helper (NDJSON streaming responses) +│ ├── update_checker.go # Auto-update check against Gitea release API +│ ├── single_instance.go # Single-instance lock via TCP port file +│ └── assets/ # Embedded static assets (qrcode.min.js, mintegral preview util) +├── scripts/ +│ ├── build.ps1 # Builds both .vsix and standalone exe into dist/ +│ └── run-standalone.ps1 # Runs the standalone app in dev mode (go run .) +├── data/ +│ └── manifest.json # Project Init manifest (team template files list) +├── aiAssets/ # Sample HTML files used for testing/reference +├── package.json # Extension manifest, VS Code settings contributions, npm scripts +├── tsconfig.json +└── README.md +``` + +--- + +## Tools + +| Tool | Description | +|---|---| +| PLEC Upload | Upload HTML playable files to internal PLEC servers. Supports two servers: Default (167.99.227.249, legacy PHP) and Backup (20.255.60.183, WordPress). Backup server uses auto-login via credentials. | +| AppLovin Playable Preview | Upload HTML files to p.applov.in and generate QR preview links. | +| Base64 Scanner | Scan HTML files for external or non-base64 asset references. | +| Send To Mobile | Serve selected HTML files over LAN and display a QR code for mobile preview. | +| MRAID Checker | Check HTML files against MRAID requirements and best practices. | +| Mintegral Checker | Validate Mintegral playable ZIPs against PlayTurbo requirements with embedded runtime preview. | +| Playworks Converter | Convert Playworks HTML into per-network variants. | +| Device Simulator | Preview HTML playables inside accurate mobile device frames with notch/Dynamic Island overlays. | +| Initialize Project | Download team template files from a shared manifest (Google Drive or direct URL). | + +--- + +## Architecture notes + +### VS Code Extension +- Each tool is a singleton webview panel (`singletonPanel` in `shared.ts`). +- The panel HTML is returned as a template string from `getHtml()` inside each tool file. +- Communication between extension host and webview uses `panel.webview.postMessage` / `onDidReceiveMessage`. +- Settings are read from `vscode.workspace.getConfiguration('hplToolbox.*')` and contributed in `package.json`. +- Session-persistent UI state (e.g. last selected server) uses `context.globalState`. + +### Standalone App +- Go HTTP server on a random localhost port, served into a WebView2 window. +- All pages return full HTML strings (no template files on disk). +- Long-running operations (uploads, scans) use `newJSONStream` to stream NDJSON progress events — the browser reads these with `ReadableStream` and updates the UI incrementally. +- Config is persisted to `%LOCALAPPDATA%\HPLToolbox\config.json` via `LoadConfig` / `SaveConfig`. +- `localStorage` is used for lightweight UI-only state (e.g. last selected server in PLEC Upload). +- Single-instance enforcement: on launch, tries to connect to a previous instance's port from a lock file and `POST /api/focus` to bring its window forward, then exits. + +### PLEC Upload — dual server detail +- **Default (legacy)**: `POST http://167.99.227.249/src/upload_HTML_Experimental.php` with `fileUpload_N` / `iterationNameUpload_N` fields. Filenames are timestamped. +- **Backup (WordPress)**: `POST http://20.255.60.183/wp-admin/admin-ajax.php` with `action=plec_upload_files`, `nonce`, `rows` (JSON array), and `file_N` fields. Login flow: POST `/wp-login.php` → GET `/file-upload/` to extract nonce. Iteration name is used as the uploaded filename. Preview URL is constructed as `/preview?theme=calcite&n1=&m1=`. + +--- + +## Build + +```powershell +npm run build # builds both .vsix and standalone .exe into dist/ +npm run build -- -Extension # .vsix only +npm run build -- -Standalone # .exe only +npm run run-standalone # dev: go run . in standalone/ +npm run compile # tsc only +npm run watch # tsc watch +``` + +Requires: Node.js, TypeScript (`npm install`), Go, optionally `vsce` and `rsrc`. + +--- + +## Key config (VS Code settings) + +All under `hplToolbox.*`: + +| Key | Default | Purpose | +|---|---|---| +| `plec.uploadUrl` | `http://167.99.227.249/src/upload_HTML_Experimental.php` | Legacy server endpoint | +| `plec.originUrl` | `http://167.99.227.249` | Origin/Referer for legacy uploads | +| `plec.wpUsername` | `pm` | WordPress username for backup server | +| `plec.wpPassword` | _(set)_ | WordPress password for backup server | +| `applovin.cookie` | _(empty)_ | `__Host-APPLOVINID` cookie for p.applov.in | +| `deviceSimulator.devicesUrl` | Google Drive link | Remote devices.json URL | +| `deviceSimulator.languagesUrl` | Google Drive link | Remote languages.json URL | diff --git a/README.md b/README.md index 410c225..846935b 100644 --- a/README.md +++ b/README.md @@ -151,3 +151,16 @@ Changed Fixed - Fixed Device Simulator not appearing in the VS Code extension launcher due to a trailing comma in tools.json causing JSON.parse to fail and fall back to a hardcoded list that excluded it. ``` + +**v1.0.1** +``` +Added + - Added backup PLEC server (20.255.60.183) as a selectable upload target in PLEC Upload. + - Server dropdown in the PLEC Upload inputs panel — Default (167.99.227.249) or Backup (20.255.60.183). + - Backup server uses WordPress authentication (auto-login, nonce fetch) — no manual token management. + - Iteration name is used as the uploaded filename on the backup server. + - Selected server persists across sessions (VS Code globalState / localStorage in standalone). + +Changed + - Build scripts moved to scripts/ and added as npm run build and npm run run-standalone. +``` \ No newline at end of file diff --git a/dist/hpl-toolbox-1.0.0.vsix b/dist/hpl-toolbox-1.0.0.vsix deleted file mode 100644 index 98928bc..0000000 Binary files a/dist/hpl-toolbox-1.0.0.vsix and /dev/null differ diff --git a/dist/hpl-toolbox-1.0.0.exe b/dist/hpl-toolbox-1.0.1.exe similarity index 64% rename from dist/hpl-toolbox-1.0.0.exe rename to dist/hpl-toolbox-1.0.1.exe index da1da58..15c610e 100644 Binary files a/dist/hpl-toolbox-1.0.0.exe and b/dist/hpl-toolbox-1.0.1.exe differ diff --git a/dist/hpl-toolbox-1.0.1.vsix b/dist/hpl-toolbox-1.0.1.vsix new file mode 100644 index 0000000..c950630 Binary files /dev/null and b/dist/hpl-toolbox-1.0.1.vsix differ diff --git a/package.json b/package.json index 2753bf2..eb3e812 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hpl-toolbox", "displayName": "HPL Toolbox", "description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.", - "version": "1.0.0", + "version": "1.0.1", "publisher": "hesukastro", "license": "UNLICENSED", "repository": { @@ -103,6 +103,16 @@ "type": "boolean", "default": false, "description": "Skip the pre-check that verifies the filename does not already exist on the preview server." + }, + "hplToolbox.plec.wpUsername": { + "type": "string", + "default": "pm", + "description": "WordPress username for the backup PLEC server (20.255.60.183)." + }, + "hplToolbox.plec.wpPassword": { + "type": "string", + "default": "nRLz#LV@y#)q@CW@z8fGEPyI", + "description": "WordPress password for the backup PLEC server (20.255.60.183)." } } }, @@ -167,7 +177,9 @@ "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./", - "vscode:prepublish": "npm run compile" + "vscode:prepublish": "npm run compile", + "build": "pwsh -File scripts/build.ps1", + "run-standalone": "pwsh -File scripts/run-standalone.ps1" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 951123d..e9bfcc0 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -15,7 +15,7 @@ param( ) $ErrorActionPreference = "Stop" -$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) Set-Location $repoRoot # If neither was specified, build both. diff --git a/scripts/run-standalone.ps1 b/scripts/run-standalone.ps1 index b8917ed..ab7085b 100644 --- a/scripts/run-standalone.ps1 +++ b/scripts/run-standalone.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = 'Stop' -$root = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) $standalone = Join-Path $root 'standalone' Push-Location $standalone diff --git a/src/tools/plecUpload.ts b/src/tools/plecUpload.ts index 3d93cca..1fe2bfc 100644 --- a/src/tools/plecUpload.ts +++ b/src/tools/plecUpload.ts @@ -6,15 +6,25 @@ import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './ const store: { panel: vscode.WebviewPanel | null } = { panel: null }; -export function openPlecUpload(_context: vscode.ExtensionContext) { +export function openPlecUpload(context: vscode.ExtensionContext) { const { panel, isNew } = singletonPanel(store, 'hplToolbox.plecUpload', 'PLEC Upload'); if (!isNew) return; panel.webview.html = getHtml(); + + // Restore last selected server + const lastServer = context.globalState.get('plec.lastServer', 'legacy'); + panel.webview.postMessage({ type: 'restoreServer', value: lastServer }); + panel.webview.onDidReceiveMessage(async (msg) => { try { if (handleClipboardAndOpen(msg)) return; + if (msg.type === 'saveServer') { + context.globalState.update('plec.lastServer', msg.value); + return; + } + if (msg.type === 'pickFolder') { const picked = await vscode.window.showOpenDialog({ canSelectFolders: true, @@ -51,7 +61,7 @@ export function openPlecUpload(_context: vscode.ExtensionContext) { } if (msg.type === 'upload') { - await handleUpload(panel, msg.rows); + await handleUpload(panel, msg.rows, msg.server); return; } } catch (err: any) { @@ -66,11 +76,7 @@ interface UploadRow { iteration: string; } -async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) { - const cfg = vscode.workspace.getConfiguration('hplToolbox.plec'); - const uploadUrl = cfg.get('uploadUrl')!; - const originUrl = cfg.get('originUrl')!; - +async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[], server: string) { const valid: UploadRow[] = []; for (const r of rows) { if (!r.path || !fs.existsSync(r.path)) { @@ -92,11 +98,21 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) { return; } + if (server === 'wordpress') { + await handleUploadWordPress(panel, valid); + } else { + await handleUploadLegacy(panel, valid); + } +} + +async function handleUploadLegacy(panel: vscode.WebviewPanel, valid: UploadRow[]) { + const cfg = vscode.workspace.getConfiguration('hplToolbox.plec'); + const uploadUrl = cfg.get('uploadUrl')!; + const originUrl = cfg.get('originUrl')!; + const stamp = timestampSuffix(); const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp)); - panel.webview.postMessage({ type: 'status', message: 'Uploading...' }); - const form = new FormData(); for (let i = 0; i < valid.length; i++) { const r = valid[i]; @@ -129,20 +145,14 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) { const text = await res.text(); if (!res.ok) { - panel.webview.postMessage({ - type: 'error', - message: `HTTP ${res.status}\n${text.slice(0, 800)}`, - }); + panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` }); return; } let json: any; try { json = JSON.parse(text); } catch { - panel.webview.postMessage({ - type: 'error', - message: `Server returned non-JSON:\n${text.slice(0, 800)}`, - }); + panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` }); return; } @@ -150,6 +160,154 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) { panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text }); } +async function wpAuthenticate(panel: vscode.WebviewPanel): Promise<{ cookie: string; nonce: string }> { + const cfg = vscode.workspace.getConfiguration('hplToolbox.plec'); + const username = cfg.get('wpUsername') || ''; + const password = cfg.get('wpPassword') || ''; + const baseUrl = 'http://20.255.60.183'; + + if (!username || !password) { + throw new Error('WordPress credentials not set. Add hplToolbox.plec.wpUsername and wpPassword in VS Code settings.'); + } + + panel.webview.postMessage({ type: 'status', message: 'Authenticating with backup server...' }); + + const loginBody = new URLSearchParams({ + log: username, + pwd: password, + 'wp-submit': 'Log In', + redirect_to: '/wp-admin/', + testcookie: '1', + }); + + const loginRes = await fetch(`${baseUrl}/wp-login.php`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': 'wordpress_test_cookie=WP Cookie check', + 'Origin': baseUrl, + 'Referer': `${baseUrl}/wp-login.php`, + 'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)', + }, + body: loginBody.toString(), + redirect: 'manual', + }); + + const location = loginRes.headers.get('location') || ''; + + // Extract Set-Cookie headers — try getSetCookie() first (Node 18+), fall back to entries() + let rawCookies: string[] = []; + if (typeof (loginRes.headers as any).getSetCookie === 'function') { + rawCookies = (loginRes.headers as any).getSetCookie(); + } else { + for (const [k, v] of (loginRes.headers as any).entries()) { + if (k.toLowerCase() === 'set-cookie') rawCookies.push(v); + } + } + const cookie = rawCookies.map((c: string) => c.split(';')[0]).join('; '); + + // WordPress sends a 302 to /wp-admin/ on success, or 200 (login page with error) on failure + const loginOk = (loginRes.status === 301 || loginRes.status === 302) && !location.includes('wp-login'); + if (!loginOk) { + const loginHtml = await loginRes.text(); + const errorMatch = loginHtml.match(/
([\s\S]*?)<\/div>/); + const wpError = errorMatch + ? errorMatch[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() + : null; + const bodyPreview = loginHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500); + throw new Error( + `WordPress login failed (HTTP ${loginRes.status}).\n` + + (wpError ? `Server: "${wpError}"\n` : '') + + `Body: ${bodyPreview}` + ); + } + if (!cookie) { + throw new Error(`Login succeeded (redirected to ${location}) but no session cookies were returned. Check server config.`); + } + + panel.webview.postMessage({ type: 'status', message: 'Fetching upload token...' }); + + const pageRes = await fetch(`${baseUrl}/file-upload/`, { + headers: { 'Cookie': cookie, 'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)' }, + }); + const pageText = await pageRes.text(); + const match = pageText.match(/"nonce"\s*:\s*"([a-f0-9]+)"/); + if (!match) { + throw new Error('Could not extract nonce from backup server page. Are you sure the account has access?'); + } + + return { cookie, nonce: match[1] }; +} + +async function handleUploadWordPress(panel: vscode.WebviewPanel, valid: UploadRow[]) { + const { cookie, nonce } = await wpAuthenticate(panel); + const baseUrl = 'http://20.255.60.183'; + + const form = new FormData(); + form.append('action', 'plec_upload_files'); + form.append('nonce', nonce); + + const rowsMeta: { field: string; iterationName: string }[] = []; + for (let i = 0; i < valid.length; i++) { + const r = valid[i]; + const idx = i + 1; + const fieldName = `file_${idx}`; + const sanitized = r.iteration.replace(/\s+/g, ''); + panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${sanitized}.html` }); + const buf = fs.readFileSync(r.path); + const file = new File([buf], `${sanitized}.html`, { type: 'text/html' }); + form.append(fieldName, file as any); + rowsMeta.push({ field: fieldName, iterationName: sanitized }); + } + form.append('rows', JSON.stringify(rowsMeta)); + + panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` }); + + let res: Response; + try { + res = await fetch(`${baseUrl}/wp-admin/admin-ajax.php`, { + method: 'POST', + body: form, + headers: { + 'Cookie': cookie, + 'Origin': baseUrl, + 'Referer': `${baseUrl}/file-upload/`, + 'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': '*/*', + }, + }); + } catch (err: any) { + panel.webview.postMessage({ type: 'error', message: 'Network error: ' + (err?.message || err) }); + return; + } + + const text = await res.text(); + if (!res.ok) { + panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` }); + return; + } + + let json: any; + try { json = JSON.parse(text); } + catch { + panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` }); + return; + } + + const files: { original: string; saved: string }[] = json.data?.files || []; + let preview: string | null = json.preview || json.previewURL || null; + if (!preview && files.length > 0) { + const params = new URLSearchParams({ theme: 'calcite' }); + files.forEach((f, i) => { + params.append(`n${i + 1}`, f.original.replace(/\.html?$/i, '')); + params.append(`m${i + 1}`, f.saved); + }); + preview = `${baseUrl}/preview?${params.toString()}`; + } + panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text }); +} + function timestampSuffix(d: Date = new Date()): string { const pad = (n: number, w = 2) => String(n).padStart(w, '0'); return ( @@ -225,6 +383,17 @@ ${getToolWebviewStyles()} .result a { color: var(--vscode-textLink-foreground); } .result-actions { margin-top: 8px; display: flex; gap: 8px; } .pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; } + .panel-header { display: flex; align-items: center; } + #serverSelect { + margin-left: auto; + background: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border); + padding: 3px 6px; + border-radius: 2px; + font-size: 12px; + cursor: pointer; + } @@ -237,6 +406,10 @@ ${getToolWebviewStyles()}

Inputs

+
@@ -377,6 +550,10 @@ ${getToolWebviewStyles()} copyResultBtn.disabled = !previewUrl; } + document.getElementById('serverSelect').addEventListener('change', (e) => { + vscode.postMessage({ type: 'saveServer', value: e.target.value }); + }); + document.getElementById('pickFolder').addEventListener('click', () => { vscode.postMessage({ type: 'pickFolder' }); }); @@ -402,7 +579,7 @@ ${getToolWebviewStyles()} } statusEl.textContent = 'Uploading...'; statusEl.classList.add('is-busy'); - vscode.postMessage({ type: 'upload', rows }); + vscode.postMessage({ type: 'upload', rows, server: document.getElementById('serverSelect').value }); }); openResultBtn.addEventListener('click', () => { if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl }); @@ -418,6 +595,10 @@ ${getToolWebviewStyles()} window.addEventListener('message', (e) => { const m = e.data; + if (m.type === 'restoreServer') { + document.getElementById('serverSelect').value = m.value; + return; + } if (m.type === 'filesSelected') { addFiles(m.files || []); } else if (m.type === 'status') { diff --git a/standalone/hpltoolbox.exe b/standalone/hpltoolbox.exe index b1abe45..32b66ab 100644 Binary files a/standalone/hpltoolbox.exe and b/standalone/hpltoolbox.exe differ diff --git a/standalone/platform.go b/standalone/platform.go index db37c20..9346dcf 100644 --- a/standalone/platform.go +++ b/standalone/platform.go @@ -16,6 +16,8 @@ type PlecConfig struct { OriginUrl string `json:"originUrl"` PreviewBase string `json:"previewBase"` SkipExistsCheck bool `json:"skipExistsCheck"` + WpUsername string `json:"wpUsername"` + WpPassword string `json:"wpPassword"` } type ApplovinConfig struct { @@ -69,6 +71,8 @@ func defaultConfig() AppConfig { OriginUrl: "http://167.99.227.249", PreviewBase: "https://playable.applovindemo.com/phaser/", SkipExistsCheck: false, + WpUsername: "pm", + WpPassword: "nRLz#LV@y#)q@CW@z8fGEPyI", }, Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000}, SendToMobile: SendToMobileConfig{Port: 0}, diff --git a/standalone/plec.go b/standalone/plec.go index 5ee6526..b25d8c0 100644 --- a/standalone/plec.go +++ b/standalone/plec.go @@ -7,6 +7,7 @@ import ( "io" "mime/multipart" "net/http" + "net/http/cookiejar" urlpkg "net/url" "os" "path/filepath" @@ -23,7 +24,13 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
-

Inputs

+
+

Inputs

+ +
@@ -54,10 +61,15 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {