Files
imgforge/public/index.html

785 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ImgForge — Image Processing Queue</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Outfit:wght@300;500;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a0c;
--surface: #111115;
--surface-2: #1a1a20;
--border: #2a2a32;
--text: #e8e8ed;
--text-dim: #7a7a88;
--accent: #ff6b35;
--accent-glow: rgba(255, 107, 53, 0.15);
--green: #34d399;
--red: #f87171;
--blue: #60a5fa;
--mono: 'JetBrains Mono', monospace;
--sans: 'Outfit', sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
min-height: 100vh;
overflow-x: hidden;
}
/* Grain overlay */
body::after {
content: '';
position: fixed;
inset: 0;
background: url('data:image/svg+xml,<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="4" stitchTiles="stitch"/></filter><rect width="100%" height="100%" filter="url(%23n)" opacity="0.03"/></svg>');
pointer-events: none;
z-index: 9999;
}
.container {
max-width: 760px;
margin: 0 auto;
padding: 60px 24px;
}
/* Header */
.logo {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
color: var(--accent);
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 8px;
}
h1 {
font-size: 42px;
font-weight: 700;
line-height: 1.1;
margin-bottom: 12px;
background: linear-gradient(135deg, var(--text) 0%, var(--text-dim) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: var(--text-dim);
font-size: 16px;
font-weight: 300;
margin-bottom: 48px;
line-height: 1.6;
}
.subtitle code {
font-family: var(--mono);
background: var(--surface-2);
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
color: var(--accent);
}
/* Drop zone */
.dropzone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 48px;
text-align: center;
transition: all 0.25s ease;
cursor: pointer;
position: relative;
background: var(--surface);
}
.dropzone.drag-over {
border-color: var(--accent);
background: var(--accent-glow);
}
.dropzone.has-file {
border-color: var(--green);
border-style: solid;
}
.dropzone-icon { font-size: 36px; margin-bottom: 12px; }
.dropzone-text { color: var(--text-dim); font-size: 15px; }
.dropzone-text strong { color: var(--text); }
.file-info {
display: none;
margin-top: 16px;
font-family: var(--mono);
font-size: 13px;
color: var(--green);
}
.dropzone.has-file .file-info { display: block; }
.dropzone.has-file .dropzone-prompt { display: none; }
input[type="file"] { display: none; }
/* Operations */
.ops-section {
margin-top: 32px;
}
.ops-section h3 {
font-family: var(--mono);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-dim);
margin-bottom: 16px;
}
.op-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.op-btn {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
color: var(--text);
font-family: var(--mono);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
}
.op-btn:hover { border-color: var(--accent); background: var(--surface-2); }
.op-btn.selected { border-color: var(--accent); background: var(--accent-glow); color: var(--accent); }
.op-btn .op-icon { font-size: 18px; margin-bottom: 6px; }
.op-btn .op-name { font-weight: 600; }
/* Operation config panels */
.op-configs {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.op-config {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.op-config label {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
}
.op-config input, .op-config select {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text);
font-family: var(--mono);
font-size: 13px;
width: 100px;
}
.op-config input:focus, .op-config select:focus {
outline: none;
border-color: var(--accent);
}
.op-tag {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
color: var(--accent);
min-width: 80px;
}
.remove-op {
margin-left: auto;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 18px;
padding: 4px;
}
.remove-op:hover { color: var(--red); }
/* Submit */
.submit-btn {
margin-top: 32px;
width: 100%;
padding: 16px;
border: none;
border-radius: 10px;
background: var(--accent);
color: #fff;
font-family: var(--mono);
font-size: 14px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s ease;
}
.submit-btn:hover { filter: brightness(1.1); transform: translateY(-1px); }
.submit-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
/* Job result panel */
.job-panel {
display: none;
margin-top: 32px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.job-panel.visible { display: block; animation: slideUp 0.3s ease; }
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.job-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.job-id {
font-family: var(--mono);
font-size: 13px;
color: var(--text-dim);
}
.job-status {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
}
.job-status.queued { background: var(--surface-2); color: var(--text-dim); }
.job-status.active { background: rgba(96, 165, 250, 0.15); color: var(--blue); }
.job-status.completed { background: rgba(52, 211, 153, 0.15); color: var(--green); }
.job-status.failed { background: rgba(248, 113, 113, 0.15); color: var(--red); }
.job-body { padding: 20px; }
/* Progress bar */
.progress-wrap {
height: 6px;
background: var(--surface-2);
border-radius: 3px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: 3px;
width: 0%;
transition: width 0.3s ease;
}
.progress-bar.done { background: var(--green); }
.stage-text {
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
margin-bottom: 16px;
}
/* Result stats */
.result-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat {
background: var(--surface-2);
border-radius: 8px;
padding: 12px;
text-align: center;
}
.stat-value {
font-family: var(--mono);
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.stat-label {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 4px;
}
.download-btn {
display: inline-block;
padding: 12px 24px;
background: var(--green);
color: #000;
font-family: var(--mono);
font-size: 13px;
font-weight: 700;
border-radius: 8px;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 1px;
transition: filter 0.2s;
}
.download-btn:hover { filter: brightness(1.1); }
.error-msg {
font-family: var(--mono);
font-size: 13px;
color: var(--red);
background: rgba(248, 113, 113, 0.1);
padding: 12px;
border-radius: 8px;
}
/* Log */
.job-log {
margin-top: 16px;
max-height: 160px;
overflow-y: auto;
font-family: var(--mono);
font-size: 11px;
color: var(--text-dim);
line-height: 1.8;
}
.job-log .log-entry { opacity: 0.7; }
.job-log .log-entry:last-child { opacity: 1; color: var(--text); }
/* API docs */
.docs {
margin-top: 64px;
padding-top: 32px;
border-top: 1px solid var(--border);
}
.docs h3 {
font-family: var(--mono);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-dim);
margin-bottom: 20px;
}
.code-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.8;
overflow-x: auto;
white-space: pre;
color: var(--text-dim);
}
.code-block .keyword { color: var(--accent); }
.code-block .string { color: var(--green); }
.code-block .comment { color: #555; }
</style>
</head>
<body>
<div class="container">
<div class="logo">ImgForge</div>
<h1>Process images<br>at queue speed</h1>
<p class="subtitle">
Upload → queue → process → download. Built on
<code>Bull</code> + <code>Sharp</code> + <code>Redis</code>.
Real-time progress via WebSocket.
<a href="/dashboard.html" style="color:var(--accent);font-family:var(--mono);font-size:13px;text-decoration:none;border-bottom:1px solid var(--accent);margin-left:4px">Open Dashboard →</a>
</p>
<!-- Upload -->
<div class="dropzone" id="dropzone">
<div class="dropzone-prompt">
<div class="dropzone-icon">📁</div>
<div class="dropzone-text">
<strong>Drop image here</strong> or click to browse<br>
JPEG, PNG, WebP, AVIF, TIFF, GIF — max 20MB
</div>
</div>
<div class="file-info" id="fileInfo"></div>
<input type="file" id="fileInput" accept="image/*" />
</div>
<!-- Operations -->
<div class="ops-section">
<h3>Operations (click to add, order matters)</h3>
<div class="op-grid">
<button class="op-btn" data-op="resize">
<div class="op-icon">📐</div>
<div class="op-name">Resize</div>
</button>
<button class="op-btn" data-op="convert">
<div class="op-icon">🔄</div>
<div class="op-name">Convert</div>
</button>
<button class="op-btn" data-op="compress">
<div class="op-icon">🗜️</div>
<div class="op-name">Compress</div>
</button>
<button class="op-btn" data-op="watermark">
<div class="op-icon">💧</div>
<div class="op-name">Watermark</div>
</button>
<button class="op-btn" data-op="strip-meta">
<div class="op-icon">🧹</div>
<div class="op-name">Strip Meta</div>
</button>
<button class="op-btn" data-op="blur">
<div class="op-icon">🌫️</div>
<div class="op-name">Blur</div>
</button>
<button class="op-btn" data-op="grayscale">
<div class="op-icon">🖤</div>
<div class="op-name">Grayscale</div>
</button>
<button class="op-btn" data-op="rotate">
<div class="op-icon"></div>
<div class="op-name">Rotate</div>
</button>
</div>
<div class="op-configs" id="opConfigs"></div>
</div>
<!-- Submit -->
<button class="submit-btn" id="submitBtn" disabled>Process Image</button>
<!-- Job result -->
<div class="job-panel" id="jobPanel">
<div class="job-header">
<span class="job-id" id="jobId"></span>
<span class="job-status queued" id="jobStatus">queued</span>
</div>
<div class="job-body">
<div class="progress-wrap"><div class="progress-bar" id="progressBar"></div></div>
<div class="stage-text" id="stageText">Waiting...</div>
<div id="resultArea"></div>
<div class="job-log" id="jobLog"></div>
</div>
</div>
<!-- API docs -->
<div class="docs">
<h3>API Usage</h3>
<div class="code-block"><span class="comment"># Submit a job</span>
<span class="keyword">curl</span> -X POST /api/jobs \
-F <span class="string">"file=@photo.jpg"</span> \
-F <span class="string">'operations=[{"op":"resize","params":{"width":800}},{"op":"convert","params":{"format":"webp"}},{"op":"compress","params":{"quality":75}}]'</span>
<span class="comment"># Poll status</span>
<span class="keyword">curl</span> /api/jobs/:jobId
<span class="comment"># Download result</span>
<span class="keyword">curl</span> -O /api/jobs/:jobId/download
<span class="comment"># List available operations</span>
<span class="keyword">curl</span> /api/operations</div>
</div>
</div>
<script>
const $ = (s) => document.querySelector(s);
const dropzone = $("#dropzone");
const fileInput = $("#fileInput");
const opConfigs = $("#opConfigs");
const submitBtn = $("#submitBtn");
const jobPanel = $("#jobPanel");
let selectedFile = null;
let operations = [];
// --- File handling ---
dropzone.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("drag-over"); });
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag-over"));
dropzone.addEventListener("drop", (e) => {
e.preventDefault();
dropzone.classList.remove("drag-over");
if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener("change", () => { if (fileInput.files[0]) setFile(fileInput.files[0]); });
function setFile(file) {
selectedFile = file;
dropzone.classList.add("has-file");
const size = (file.size / 1024).toFixed(1);
$("#fileInfo").textContent = `${file.name} (${size} KB)`;
updateSubmit();
}
// --- Operations ---
const OP_CONFIGS = {
resize: () => `
<span class="op-tag">resize</span>
<label>W <input type="number" data-param="width" placeholder="800" min="1" max="10000"></label>
<label>H <input type="number" data-param="height" placeholder="auto" min="1" max="10000"></label>
<label>Fit <select data-param="fit">
<option value="cover">cover</option>
<option value="contain">contain</option>
<option value="fill">fill</option>
<option value="inside">inside</option>
</select></label>`,
convert: () => `
<span class="op-tag">convert</span>
<label>Format <select data-param="format">
<option value="webp">WebP</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="avif">AVIF</option>
</select></label>`,
compress: () => `
<span class="op-tag">compress</span>
<label>Quality <input type="number" data-param="quality" value="75" min="1" max="100"></label>`,
watermark: () => `
<span class="op-tag">watermark</span>
<label>Text <input type="text" data-param="text" placeholder="© ImgForge" style="width:160px"></label>
<label>Size <input type="number" data-param="fontSize" value="48" min="10" max="200" style="width:70px"></label>`,
"strip-meta": () => `<span class="op-tag">strip-meta</span><span style="color:var(--text-dim);font-size:12px">No config needed</span>`,
blur: () => `
<span class="op-tag">blur</span>
<label>Sigma <input type="number" data-param="sigma" value="3" min="0.3" max="100" step="0.1"></label>`,
grayscale: () => `<span class="op-tag">grayscale</span><span style="color:var(--text-dim);font-size:12px">No config needed</span>`,
rotate: () => `
<span class="op-tag">rotate</span>
<label>Angle <input type="number" data-param="angle" value="90" step="90"></label>`,
};
document.querySelectorAll(".op-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const op = btn.dataset.op;
const idx = operations.length;
operations.push({ op, params: {} });
const row = document.createElement("div");
row.className = "op-config";
row.dataset.idx = idx;
row.innerHTML = OP_CONFIGS[op]() + `<button class="remove-op" title="Remove">×</button>`;
row.querySelector(".remove-op").addEventListener("click", () => {
operations[idx] = null;
row.remove();
updateSubmit();
});
opConfigs.appendChild(row);
updateSubmit();
});
});
function gatherOperations() {
return Array.from(opConfigs.querySelectorAll(".op-config"))
.map((row) => {
const idx = parseInt(row.dataset.idx);
const op = operations[idx];
if (!op) return null;
const params = {};
row.querySelectorAll("[data-param]").forEach((input) => {
const val = input.value;
if (val === "") return;
params[input.dataset.param] = isNaN(val) ? val : Number(val);
});
return { op: op.op, params };
})
.filter(Boolean);
}
function updateSubmit() {
const validOps = operations.filter(Boolean);
submitBtn.disabled = !selectedFile || validOps.length === 0;
}
// --- Submit ---
submitBtn.addEventListener("click", async () => {
if (!selectedFile) return;
const ops = gatherOperations();
if (ops.length === 0) return;
submitBtn.disabled = true;
submitBtn.textContent = "Uploading...";
const form = new FormData();
form.append("file", selectedFile);
form.append("operations", JSON.stringify(ops));
try {
const res = await fetch("/api/jobs", { method: "POST", body: form });
const data = await res.json();
if (!res.ok) {
alert(data.error || "Upload failed");
submitBtn.disabled = false;
submitBtn.textContent = "Process Image";
return;
}
// Show job panel and connect WS
showJob(data.jobId);
connectWs(data.jobId);
} catch (err) {
alert("Network error: " + err.message);
}
submitBtn.textContent = "Process Image";
});
// --- Job panel ---
function showJob(id) {
jobPanel.classList.add("visible");
$("#jobId").textContent = `Job #${id}`;
setStatus("queued");
$("#progressBar").style.width = "0%";
$("#progressBar").classList.remove("done");
$("#stageText").textContent = "Waiting in queue...";
$("#resultArea").innerHTML = "";
$("#jobLog").innerHTML = "";
}
function setStatus(status) {
const el = $("#jobStatus");
el.textContent = status;
el.className = "job-status " + status;
}
function addLog(msg) {
const log = $("#jobLog");
const entry = document.createElement("div");
entry.className = "log-entry";
const ts = new Date().toLocaleTimeString();
entry.textContent = `[${ts}] ${msg}`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function formatBytes(b) {
if (b < 1024) return b + " B";
if (b < 1048576) return (b / 1024).toFixed(1) + " KB";
return (b / 1048576).toFixed(1) + " MB";
}
// --- WebSocket ---
function connectWs(jobId) {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/ws?jobId=${jobId}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
switch (msg.type) {
case "subscribed":
addLog("Connected to job stream");
break;
case "started":
setStatus("active");
addLog(`Processing: ${msg.operations.join(" → ")}`);
break;
case "info":
addLog(`Input: ${msg.input.width}×${msg.input.height} ${msg.input.format} (${formatBytes(msg.input.size)})`);
break;
case "progress":
$("#progressBar").style.width = msg.progress + "%";
$("#stageText").textContent = msg.stage;
addLog(msg.stage);
break;
case "completed": {
setStatus("completed");
$("#progressBar").style.width = "100%";
$("#progressBar").classList.add("done");
$("#stageText").textContent = "Done!";
addLog(`Completed in ${msg.processingTime}ms`);
const savings = msg.savings.percent;
const savingsColor = savings > 0 ? "var(--green)" : "var(--red)";
$("#resultArea").innerHTML = `
<div class="result-stats">
<div class="stat">
<div class="stat-value">${msg.output.width}×${msg.output.height}</div>
<div class="stat-label">Dimensions</div>
</div>
<div class="stat">
<div class="stat-value">${formatBytes(msg.output.size)}</div>
<div class="stat-label">Size</div>
</div>
<div class="stat">
<div class="stat-value" style="color:${savingsColor}">${savings > 0 ? "" : "+"}${Math.abs(savings)}%</div>
<div class="stat-label">Savings</div>
</div>
</div>
<a class="download-btn" href="/api/jobs/${jobId}/download">↓ Download Result</a>
`;
submitBtn.disabled = false;
ws.close();
break;
}
case "failed":
setStatus("failed");
$("#stageText").textContent = "Failed";
$("#resultArea").innerHTML = `<div class="error-msg">Error: ${msg.error}</div>`;
addLog(`FAILED: ${msg.error}`);
submitBtn.disabled = false;
ws.close();
break;
}
};
ws.onerror = () => addLog("WebSocket error — falling back to polling");
ws.onclose = () => addLog("Stream closed");
}
</script>
</body>
</html>