commit a281b7deebfd7da784bb4e5b061d2fcdbc724691 Author: Jonah Date: Mon Jul 6 00:46:21 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..085e8ba --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Thumbs.db diff --git a/index.html b/index.html new file mode 100644 index 0000000..e7f951d --- /dev/null +++ b/index.html @@ -0,0 +1,326 @@ + + + + + + + CSG Image Upload Tool — CollisionStoneGuard + + + + + + + + +
+
+
+ +
+

CSG Image Tool

+

CollisionStoneGuard Product Photo Uploader

+
+
+ +
+
+ + + + +
+ + +
+
+

Import Photos

+
+ +
+
+
+ + + + + +
+

Drop photos here

+

From your DPP4 output folder or any folder

+
+ + +
+ + +
+
+ + +
+ + +
+
+

Process & Preview

+ +
+ +
+
+ + +
+
+ + +
+ + + + + +
+ +
+ +
+ + +
+
+ + +
+
+

Upload to WooCommerce

+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..e257bc7 --- /dev/null +++ b/script.js @@ -0,0 +1,1721 @@ +/* ═══════════════════════════════════════════════════════════ + CSG Image Upload Tool — script.js + ═══════════════════════════════════════════════════════════ */ + +// ── State ───────────────────────────────────────────────── +const state = { + files: [], // File[] + processed: [], // { file, originalUrl, blob, processedUrl, sku, productId, productName, mediaId, status } + watermarkImg: null, // HTMLImageElement +}; + +// ── Settings ─────────────────────────────────────────────── +const DEFAULTS = { + siteUrl: 'https://collisionstoneguard.com', + ck: 'ck_a989ecc7f7009fe156ce83455eca4ef5b05bfd7a', + cs: 'cs_32e2c692b2c53ff8722eebde75c27190337c6118', + wmOpacity: 30, + wmSize: 25, + bgThresh: 25, + quality: 75, + maxDimension: 1500, + removeBg: true, + dropShadow: true, + watermark: true, + wmDataUrl: null, + wpUser: '', + wpPass: '', + shadowIntensity: 22, + solidBlack: false, +}; + +function loadSettings() { + try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem('csg_settings') || '{}') }; } + catch { return { ...DEFAULTS }; } +} +function saveSettings(s) { localStorage.setItem('csg_settings', JSON.stringify(s)); } +let cfg = loadSettings(); + +// ── DOM refs ─────────────────────────────────────────────── +const $ = id => document.getElementById(id); +const stepPanels = document.querySelectorAll('.step-panel'); +const stepDots = document.querySelectorAll('#steps-nav .step'); +const connectors = document.querySelectorAll('.step-connector'); + +// ── Navigation ───────────────────────────────────────────── +function goStep(n) { + stepPanels.forEach(p => p.classList.remove('active')); + stepDots.forEach((d, i) => { + d.classList.toggle('active', i + 1 === n); + d.classList.toggle('done', i + 1 < n); + }); + connectors.forEach((c, i) => c.classList.toggle('done', i + 1 < n)); + $(`step-${n}`).classList.add('active'); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +// ── Step 1: Import ───────────────────────────────────────── + +$('btn-browse-files').onclick = () => $('input-files').click(); +$('btn-browse-folder').onclick = () => $('input-folder').click(); +$('input-files').onchange = e => handleFiles(e.target.files); +$('input-folder').onchange = e => handleFiles(e.target.files); + +const dropZone = $('drop-zone'); +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'); + handleFiles(e.dataTransfer.files); +}); + +function handleFiles(fileList) { + const imgs = Array.from(fileList).filter(f => f.type.startsWith('image/')); + if (!imgs.length) return; + state.files = imgs; + renderImportPreview(); +} + +function renderImportPreview() { + const grid = $('import-grid'); + grid.innerHTML = ''; + state.files.forEach(f => { + const div = document.createElement('div'); + div.className = 'thumb-item'; + const img = document.createElement('img'); + img.src = URL.createObjectURL(f); + const name = document.createElement('div'); + name.className = 'thumb-name'; + name.textContent = f.name; + div.append(img, name); + grid.appendChild(div); + }); + $('import-count').textContent = `${state.files.length} image${state.files.length !== 1 ? 's' : ''} selected`; + $('import-preview').classList.remove('hidden'); + dropZone.style.display = 'none'; +} + +$('btn-clear-import').onclick = () => { + state.files = []; + $('import-preview').classList.add('hidden'); + dropZone.style.display = ''; + $('input-files').value = ''; + $('input-folder').value = ''; +}; + +$('btn-to-process').onclick = () => { + buildProcessGrid(); + goStep(2); +}; + +// ── Step 2: Process ──────────────────────────────────────── +$('quality-slider').oninput = e => $('quality-display').textContent = e.target.value; +$('bg-tol-slider').oninput = e => $('bg-tol-display').textContent = e.target.value; +$('btn-process-all').onclick = () => processAll(); +$('btn-back-to-1').onclick = () => goStep(1); +$('btn-to-upload').onclick = () => { buildUploadGrid(); goStep(3); }; + +function buildProcessGrid() { + const grid = $('process-grid'); + grid.innerHTML = ''; + state.processed = state.files.map(f => ({ file: f, originalUrl: URL.createObjectURL(f), blob: null, processedUrl: null, sku: '', productId: null, productName: null, mediaId: null, status: 'pending' })); + state.processed.forEach((item, i) => { + const card = document.createElement('div'); + card.className = 'process-card'; + card.id = `pcard-${i}`; + card.innerHTML = ` +
+
Original
+
+
Processed
+
+
+
${item.file.name}
+
+ + + +
+
Ready
+
`; + grid.appendChild(card); + }); +} + +async function processAll() { + for (let i = 0; i < state.processed.length; i++) await processSingle(i); +} + +async function processSingle(i) { + const item = state.processed[i]; + setStatus(i, 'processing', 'Processing…'); + try { + const quality = parseInt($('quality-slider').value) / 100; + const removeBg = $('toggle-bg').checked; + const shadow = $('toggle-shadow').checked; + const wm = $('toggle-watermark').checked; + const crop = $('toggle-crop').checked; + const solidBlack = item._solidBlack !== undefined ? item._solidBlack : ($('toggle-silhouette') ? $('toggle-silhouette').checked : false); + cfg.bgThresh = parseInt($('bg-tol-slider').value); + const nudge = (item._partX !== undefined && item._partY !== undefined) + ? { x: item._partX, y: item._partY } + : null; + item.blob = await processImage(item.file, { + quality, removeBg, shadow, wm, crop, nudge, solidBlack, + shadowIntensity: item._shadowIntensity !== undefined ? item._shadowIntensity : cfg.shadowIntensity, + cropPadding: item._cropPadding !== undefined ? item._cropPadding : 5, + manualMatte: item._manualMatte || null, + manualLasso: item._manualLasso || null, + manualErases: item._eraseLassos || null, + wmPos: item._wmPos || null + }); + item.processedUrl = URL.createObjectURL(item.blob); + $(`proc-${i}`).src = item.processedUrl; + setStatus(i, 'done', `Done — ${(item.blob.size / 1024).toFixed(0)} KB`); + } catch(e) { + setStatus(i, 'error', `Error: ${e.message}`); + } +} + +function setStatus(i, cls, msg) { + const dot = $(`sdot-${i}`); const txt = $(`stxt-${i}`); + if (dot) { dot.className = 'status-dot ' + cls; } + if (txt) txt.textContent = msg; +} + +function downloadSingle(i) { + const item = state.processed[i]; + if (!item.blob) return alert('Process the image first.'); + const ext = item.blob.type === 'image/webp' ? '.webp' : '.jpg'; + const a = document.createElement('a'); + a.href = item.processedUrl; + a.download = item.file.name.replace(/\.[^.]+$/, '') + '_processed' + ext; + a.click(); +} + +// ── Image Processing Pipeline ────────────────────────────── +async function processImage(file, opts) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + try { resolve(runPipeline(img, opts)); } + catch(e) { reject(e); } + }; + img.onerror = () => reject(new Error('Failed to load image')); + img.src = URL.createObjectURL(file); + }); +} + +function applyLassoMask(ctx, W, H, lassoPoints) { + const temp = document.createElement('canvas'); + temp.width = W; temp.height = H; + const tctx = temp.getContext('2d'); + + tctx.beginPath(); + tctx.moveTo(lassoPoints[0].x * W, lassoPoints[0].y * H); + for (let i = 1; i < lassoPoints.length; i++) { + tctx.lineTo(lassoPoints[i].x * W, lassoPoints[i].y * H); + } + tctx.closePath(); + tctx.fillStyle = '#ffffff'; + tctx.fill(); + + tctx.globalCompositeOperation = 'source-in'; + tctx.drawImage(ctx.canvas, 0, 0); + + ctx.clearRect(0, 0, W, H); + ctx.drawImage(temp, 0, 0); +} + +function applyEraseLasso(ctx, W, H, lassoPoints) { + ctx.save(); + ctx.beginPath(); + ctx.moveTo(lassoPoints[0].x * W, lassoPoints[0].y * H); + for (let i = 1; i < lassoPoints.length; i++) { + ctx.lineTo(lassoPoints[i].x * W, lassoPoints[i].y * H); + } + ctx.closePath(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = '#ffffff'; + ctx.fill(); + ctx.restore(); +} + +function getProcessedCanvas(img, { removeBg, shadow, wm, crop, manualMatte, manualLasso, manualErases, nudge, shadowIntensity, solidBlack, bgThresh, cropPadding = 5, wmPos }) { + const canvas = document.createElement('canvas'); + const W = img.naturalWidth, H = img.naturalHeight; + canvas.width = W; canvas.height = H; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + + // ── Step 1: Detect background color (either from selection boundary or corners) ── + const imgData = ctx.getImageData(0, 0, W, H); + const d = imgData.data; + let rSum = 0, gSum = 0, bSum = 0, n = 0; + + if (manualLasso && manualLasso.length > 2) { + // Sample along the lasso path + const sampleStep = Math.max(1, Math.floor(manualLasso.length / 100)); + for (let i = 0; i < manualLasso.length; i += sampleStep) { + const pt = manualLasso[i]; + const x = Math.max(0, Math.min(W - 1, Math.floor(pt.x * W))); + const y = Math.max(0, Math.min(H - 1, Math.floor(pt.y * H))); + const idx = (y * W + x) * 4; + rSum += d[idx]; gSum += d[idx + 1]; bSum += d[idx + 2]; n++; + } + } else if (manualMatte && manualMatte.w > 0 && manualMatte.h > 0) { + // Sample along the boundary of the crop box + const { x: bx, y: by, w: bw, h: bh } = manualMatte; + const x0 = Math.floor(bx * W), y0 = Math.floor(by * H); + const x1 = Math.min(W - 1, Math.floor((bx + bw) * W)); + const y1 = Math.min(H - 1, Math.floor((by + bh) * H)); + + // Sample top and bottom edges (every 10th pixel) + for (let x = x0; x <= x1; x += 10) { + const idxT = (y0 * W + x) * 4; + const idxB = (y1 * W + x) * 4; + rSum += d[idxT] + d[idxB]; + gSum += d[idxT + 1] + d[idxB + 1]; + bSum += d[idxT + 2] + d[idxB + 2]; + n += 2; + } + // Sample left and right edges (every 10th pixel) + for (let y = y0; y <= y1; y += 10) { + const idxL = (y * W + x0) * 4; + const idxR = (y * W + x1) * 4; + rSum += d[idxL] + d[idxR]; + gSum += d[idxL + 1] + d[idxR + 1]; + bSum += d[idxL + 2] + d[idxR + 2]; + n += 2; + } + } + + // Fallback if no selection or sample failed + if (n === 0) { + const sampleR = Math.max(1, Math.floor(Math.min(W, H) * 0.02)); + const corners = [[0,0],[W-1,0],[0,H-1],[W-1,H-1]]; + for (const [cx,cy] of corners) { + for (let dy = 0; dy < sampleR; dy++) { + for (let dx = 0; dx < sampleR; dx++) { + const x = Math.min(W-1, cx === 0 ? dx : cx-dx); + const y = Math.min(H-1, cy === 0 ? dy : cy-dy); + const idx = (y*W+x)*4; + rSum += d[idx]; gSum += d[idx+1]; bSum += d[idx+2]; n++; + } + } + } + } + + const bgColor = { r: rSum/n, g: gSum/n, b: bSum/n }; + + // ── Step 2: Apply manual box mask (if any) ── + if (manualMatte && manualMatte.w > 0 && manualMatte.h > 0) { + const { x, y, w, h } = manualMatte; + const mx = x * W, my = y * H, mw = w * W, mh = h * H; + const temp = document.createElement('canvas'); + temp.width = W; temp.height = H; + const tctx = temp.getContext('2d'); + tctx.drawImage(canvas, mx, my, mw, mh, mx, my, mw, mh); + ctx.clearRect(0, 0, W, H); + ctx.drawImage(temp, 0, 0); + } + + // ── Step 3: Apply manual lasso mask (if any) ── + if (manualLasso && manualLasso.length > 2) { + applyLassoMask(ctx, W, H, manualLasso); + } + + // ── Step 4: Remove background ── + const thresh = bgThresh !== undefined ? bgThresh : cfg.bgThresh; + if (removeBg) { + removeWhiteBg(ctx, W, H, thresh, bgColor, solidBlack); + } + + // ── Step 4.5: Apply manual erase lassos (if any) ── + if (manualErases && manualErases.length > 0) { + for (const eraseLasso of manualErases) { + if (eraseLasso.length > 2) { + applyEraseLasso(ctx, W, H, eraseLasso); + } + } + } + + // ── Step 5: Center & crop to content bounding box ── + let workCanvas = canvas; + let finalBounds = { minX: 0, minY: 0, width: W, height: H }; + if (removeBg && crop) { + const bounds = (typeof edState !== 'undefined' && edState.idx !== null && edState.lockedBounds) + ? edState.lockedBounds + : getContentBounds(ctx, W, H); + if (bounds.width > 0 && bounds.height > 0) { + workCanvas = cropAndCenter(canvas, bounds, nudge, cropPadding); + finalBounds = bounds; + } + } + + const outW = workCanvas.width, outH = workCanvas.height; + const out = document.createElement('canvas'); + out.width = outW; out.height = outH; + const octx = out.getContext('2d'); + + if (!removeBg) { + octx.fillStyle = '#ffffff'; + octx.fillRect(0, 0, outW, outH); + } + + if (shadow && removeBg) { + const alpha = (shadowIntensity !== undefined ? shadowIntensity : 22) / 100; + octx.shadowColor = `rgba(0,0,0,${alpha})`; + octx.shadowBlur = Math.round(outW * 0.02); + octx.shadowOffsetX = 0; + octx.shadowOffsetY = Math.round(outW * 0.01); + } + octx.drawImage(workCanvas, 0, 0); + octx.shadowColor = 'transparent'; + + if (wm && state.watermarkImg) { + const wmSize = Math.round(outW * (cfg.wmSize / 100)); + const ratio = state.watermarkImg.naturalHeight / state.watermarkImg.naturalWidth; + const wmH = Math.round(wmSize * ratio); + const pos = wmPos + ? { x: Math.round(wmPos.xPct * outW), y: Math.round(wmPos.yPct * outH) } + : bestWatermarkPos(octx, outW, outH, wmSize, wmH); + octx.globalAlpha = cfg.wmOpacity / 100; + octx.drawImage(state.watermarkImg, pos.x, pos.y, wmSize, wmH); + octx.globalAlpha = 1; + } + + // ── Step 6: Scale down to maxDimension if needed ── + const maxDim = cfg.maxDimension || 1500; + let finalCanvas = out; + if (outW > maxDim || outH > maxDim) { + const scale = maxDim / Math.max(outW, outH); + const scaleW = Math.round(outW * scale); + const scaleH = Math.round(outH * scale); + const scaled = document.createElement('canvas'); + scaled.width = scaleW; scaled.height = scaleH; + const sctx = scaled.getContext('2d'); + if (!removeBg) { sctx.fillStyle = '#ffffff'; sctx.fillRect(0, 0, scaleW, scaleH); } + sctx.drawImage(out, 0, 0, scaleW, scaleH); + finalCanvas = scaled; + } + + // Save rendering metrics back to edState if rendering in the editor viewport + if (typeof edState !== 'undefined' && edState.idx !== null && edState.originalImg && img.src === edState.originalImg.src) { + edState.renderBounds = finalBounds; + edState.renderNudge = nudge || { x: 0, y: 0 }; + edState.renderCropPadding = cropPadding; + } + + return finalCanvas; +} + +function runPipeline(img, opts) { + const finalCanvas = getProcessedCanvas(img, opts); + const mime = opts.removeBg ? 'image/webp' : 'image/jpeg'; + return new Promise(res => finalCanvas.toBlob(res, mime, opts.quality)); +} + +// Returns bounding box of all non-transparent pixels +function getContentBounds(ctx, W, H) { + const data = ctx.getImageData(0, 0, W, H).data; + let minX = W, maxX = 0, minY = H, maxY = 0, found = false; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + if (data[(y * W + x) * 4 + 3] > 8) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + found = true; + } + } + } + return found + ? { minX, minY, width: maxX - minX + 1, height: maxY - minY + 1 } + : { minX: 0, minY: 0, width: 0, height: 0 }; +} + +// Crops to bounds with padding, centers onto a square canvas +function cropAndCenter(srcCanvas, bounds, nudge, padPct = 5) { + const pad = Math.round(Math.max(bounds.width, bounds.height) * (padPct / 100)); + const side = Math.max(bounds.width, bounds.height) + pad * 2; + const out = document.createElement('canvas'); + out.width = side; + out.height = side; + const ctx = out.getContext('2d'); + // transparent — no fillRect + let dx = Math.round((side - bounds.width) / 2); + let dy = Math.round((side - bounds.height) / 2); + + if (nudge) { + dx += nudge.x; + dy += nudge.y; + } + + ctx.drawImage( + srcCanvas, + bounds.minX, bounds.minY, bounds.width, bounds.height, + dx, dy, bounds.width, bounds.height + ); + return out; +} + +function removeWhiteBg(ctx, W, H, tolerance, bgColor, solidBlack) { + const imgData = ctx.getImageData(0, 0, W, H); + const d = imgData.data; + const { r: bgR, g: bgG, b: bgB } = bgColor; + + // ── Step 1: Flood-fill from all edges using original color-distance ── + const visited = new Uint8Array(W * H); + const queue = new Int32Array(W * H); + let head = 0, tail = 0; + + const isBg = pos => { + const i = pos*4; + const dr = d[i]-bgR, dg = d[i+1]-bgG, db = d[i+2]-bgB; + return Math.sqrt(dr*dr + dg*dg + db*db) <= tolerance; + }; + const enqueue = pos => { + if (!visited[pos] && (d[pos*4+3] === 0 || isBg(pos))) { + visited[pos]=1; + queue[tail++]=pos; + } + }; + + // Start flood from all edges AND any existing transparent pixels (from manual crop) + for (let i = 0; i < W*H; i++) { + if (d[i*4+3] === 0) enqueue(i); + } + for (let x = 0; x < W; x++) { enqueue(x); enqueue((H-1)*W+x); } + for (let y = 0; y < H; y++) { enqueue(y*W); enqueue(y*W+W-1); } + + while (head < tail) { + const pos = queue[head++]; + d[pos*4+3] = 0; + const x = pos%W, y = (pos/W)|0; + if (x > 0) enqueue(pos-1); + if (x < W-1) enqueue(pos+1); + if (y > 0) enqueue(pos-W); + if (y < H-1) enqueue(pos+W); + } + + // ── Step 2: Run Connected Component Noise Filter to wipe out isolated shadow/crease islands ── + filterNoise(d, W, H); + + // ── Step 3: Turn all remaining non-transparent pixels to solid black (perfect silhouette) ── + if (solidBlack) { + for (let i = 0; i < W * H; i++) { + if (d[i*4+3] > 0) { + d[i*4] = 0; // Red + d[i*4+1] = 0; // Green + d[i*4+2] = 0; // Blue + } + } + } + + ctx.putImageData(imgData, 0, 0); +} + +function filterNoise(d, W, H) { + const visited = new Uint8Array(W * H); + const queue = new Int32Array(W * H); + // Dynamically scale threshold: 0.3% of total pixels, minimum of 500px + const minPixels = Math.max(500, Math.round(W * H * 0.003)); + + for (let i = 0; i < W * H; i++) { + if (d[i*4+3] === 0 || visited[i]) continue; + + // Start BFS to label the connected component + let head = 0, tail = 0; + queue[tail++] = i; + visited[i] = 1; + + while (head < tail) { + const pos = queue[head++]; + const x = pos % W; + const y = (pos / W) | 0; + + if (x > 0) { + const n = pos - 1; + if (d[n*4+3] > 0 && !visited[n]) { visited[n] = 1; queue[tail++] = n; } + } + if (x < W - 1) { + const n = pos + 1; + if (d[n*4+3] > 0 && !visited[n]) { visited[n] = 1; queue[tail++] = n; } + } + if (y > 0) { + const n = pos - W; + if (d[n*4+3] > 0 && !visited[n]) { visited[n] = 1; queue[tail++] = n; } + } + if (y < H - 1) { + const n = pos + W; + if (d[n*4+3] > 0 && !visited[n]) { visited[n] = 1; queue[tail++] = n; } + } + } + + // If the component size is below the threshold, erase it (make it transparent) + if (tail < minPixels) { + for (let j = 0; j < tail; j++) { + d[queue[j]*4+3] = 0; + } + } + } +} + +function bestWatermarkPos(ctx, W, H, wmW, wmH) { + // Tighter padding — stay close to the part + const pad = Math.round(Math.min(W, H) * 0.015); + const corners = [ + { x: pad, y: pad }, + { x: W-wmW-pad, y: pad }, + { x: pad, y: H-wmH-pad }, + { x: W-wmW-pad, y: H-wmH-pad }, + ]; + const imgData = ctx.getImageData(0, 0, W, H); + const d = imgData.data; + let best = corners[3], bestScore = -1; + const step = 4; + for (const c of corners) { + let score = 0, count = 0; + for (let y = c.y; y < c.y+wmH && y < H; y += step) { + for (let x = c.x; x < c.x+wmW && x < W; x += step) { + const idx = (y*W+x)*4; + // Strongly prefer transparent (removed BG) areas + score += d[idx+3] === 0 ? 255 : (d[idx]+d[idx+1]+d[idx+2])/3; + count++; + } + } + const avg = count ? score/count : 0; + if (avg > bestScore) { bestScore = avg; best = c; } + } + return best; +} + +// ── Step 3: Upload ───────────────────────────────────────── +$('btn-back-to-2').onclick = () => goStep(2); +$('btn-upload-all').onclick = async () => { + for (let i = 0; i < state.processed.length; i++) { + const item = state.processed[i]; + if (item.productId && item.blob) await uploadItem(i); + } +}; + +function buildUploadGrid() { + const grid = $('upload-grid'); + grid.innerHTML = ''; + $('upload-log').classList.remove('hidden'); + $('log-entries').innerHTML = ''; + + state.processed.forEach((item, i) => { + const row = document.createElement('div'); + row.className = 'upload-row'; + row.id = `urow-${i}`; + const thumb = item.processedUrl || item.originalUrl; + row.innerHTML = ` + +
${item.file.name}
+
+ +
+ + +
+ +
+
+
+
+
+ `; + grid.appendChild(row); + + // Auto-populate SKU from filename (everything before the extension) + const fileNameSku = item.file.name.replace(/\.[^.]+$/, ''); + item.sku = fileNameSku; + const inp = row.querySelector(`#sku-${i}`); + if (inp) { + inp.value = item.sku; + // Auto-trigger search so the user doesn't have to click "Find" + setTimeout(() => searchSku(i), 100); + } + }); +} + +async function searchSku(i) { + const sku = $(`sku-${i}`).value.trim(); + if (!sku) return; + state.processed[i].sku = sku; + const matchDiv = $(`match-${i}`); + matchDiv.style.display = 'block'; + matchDiv.className = 'product-match'; + matchDiv.textContent = 'Searching…'; + + try { + // 1. Try exact SKU match first + let res = await apiGet(`/wp-json/wc/v3/products?sku=${encodeURIComponent(sku)}`); + + // 2. If no exact match, try a general search (finds partials or variations) + if (res.length === 0) { + addLog('info', `[Search] No exact SKU match for "${sku}". Trying general search…`); + res = await apiGet(`/wp-json/wc/v3/products?search=${encodeURIComponent(sku)}`); + } + + if (res.length === 0) { + matchDiv.className = 'product-nomatch'; + matchDiv.textContent = '✕ No product found'; + state.processed[i].productId = null; + addLog('error', `[Search] No results found for "${sku}" via SKU or Search filters.`); + } else { + // Pick the first result + const product = res[0]; + state.processed[i].productId = product.id; + state.processed[i].productName = product.name; + matchDiv.className = 'product-match'; + matchDiv.textContent = `✓ ${product.name} (ID: ${product.id})`; + addLog('success', `[Search] Found: ${product.name} (SKU: ${product.sku || 'N/A'})`); + } + } catch(e) { + matchDiv.className = 'product-nomatch'; + matchDiv.textContent = `✕ Error: ${e.message}`; + addLog('error', `[Search] API Error: ${e.message}`); + } +} + +async function uploadItem(i) { + const item = state.processed[i]; + if (!item.blob && !state.processed[i].file) { alert('Process the image first.'); return; } + if (!item.productId) { alert('Find a product SKU first.'); return; } + + const blob = item.blob || await processImage(item.file, { + quality: cfg.quality/100, + removeBg: cfg.removeBg, + shadow: cfg.dropShadow, + wm: cfg.watermark, + crop: $('toggle-crop').checked, + manualMatte: item._manualMatte || null, + manualLasso: item._manualLasso || null, + manualErases: item._eraseLassos || null, + nudge: (item._partX !== undefined && item._partY !== undefined) ? { x: item._partX, y: item._partY } : null, + cropPadding: item._cropPadding !== undefined ? item._cropPadding : 5, + shadowIntensity: item._shadowIntensity !== undefined ? item._shadowIntensity : cfg.shadowIntensity, + solidBlack: item._solidBlack !== undefined ? item._solidBlack : ($('toggle-silhouette') ? $('toggle-silhouette').checked : false), + wmPos: item._wmPos || null + }); + setUploadStatus(i, 'Uploading to Media Library…', 30); + addLog('info', `[${item.file.name}] Uploading to WordPress Media Library…`); + + try { + const isWebP = blob.type === 'image/webp'; + const ext = isWebP ? '.webp' : '.jpg'; + const mime = isWebP ? 'image/webp' : 'image/jpeg'; + const fname = item.file.name.replace(/\.[^.]+$/, '') + ext; + + // Determine auth for Media Library (Core WP) + const mediaAuth = (cfg.wpUser && cfg.wpPass) + ? 'Basic ' + btoa(cfg.wpUser + ':' + cfg.wpPass) + : 'Basic ' + btoa(cfg.ck + ':' + cfg.cs); + + const mediaRes = await fetch(`${cfg.siteUrl}/wp-json/wp/v2/media`, { + method: 'POST', + headers: { + 'Authorization': mediaAuth, + 'Content-Disposition': `attachment; filename="${fname}"`, + 'Content-Type': mime, + }, + body: blob, + }); + if (!mediaRes.ok) { const t = await mediaRes.text(); throw new Error(`Media upload failed: ${mediaRes.status} — ${t.slice(0,120)}`); } + const mediaData = await mediaRes.json(); + item.mediaId = mediaData.id; + addLog('success', `[${item.file.name}] Media uploaded — ID: ${item.mediaId}`); + setUploadStatus(i, 'Linking to product…', 65); + + const productRes = await fetch(`${cfg.siteUrl}/wp-json/wc/v3/products/${item.productId}`, { + method: 'PUT', + headers: { + 'Authorization': 'Basic ' + btoa(cfg.ck + ':' + cfg.cs), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ images: [{ id: item.mediaId }] }), + }); + if (!productRes.ok) { const t = await productRes.text(); throw new Error(`Product update failed: ${productRes.status} — ${t.slice(0,120)}`); } + setUploadStatus(i, '✓ Done!', 100); + $(`urow-${i}`).style.borderColor = 'var(--success)'; + addLog('success', `[${item.file.name}] ✓ Featured image set on "${item.productName}"`); + } catch(e) { + setUploadStatus(i, `✕ ${e.message}`, 0); + $(`urow-${i}`).style.borderColor = 'var(--danger)'; + addLog('error', `[${item.file.name}] ✕ ${e.message}`); + if (e.message.includes('Failed to fetch') || e.message.includes('NetworkError')) { + addLog('error', '⚠ Possible CORS issue. Try running this tool via: npx serve . (then open http://localhost:3000)'); + } + } +} + +function setUploadStatus(i, msg, pct) { + const s = $(`ustatus-${i}`); const b = $(`uprog-${i}`); + if (s) s.textContent = msg; + if (b) b.style.width = pct + '%'; +} + +function addLog(type, msg) { + const el = document.createElement('div'); + el.className = `log-entry ${type}`; + el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; + const log = $('log-entries'); + log.prepend(el); +} + +// ── WooCommerce API helpers ──────────────────────────────── +async function apiGet(path) { + const res = await fetch(`${cfg.siteUrl}${path}`, { + headers: { 'Authorization': 'Basic ' + btoa(cfg.ck + ':' + cfg.cs) } + }); + if (!res.ok) throw new Error(`API error ${res.status}`); + return res.json(); +} + +// ── Settings Modal ───────────────────────────────────────── +$('btn-settings').onclick = openSettings; +$('btn-close-settings').onclick = closeSettings; +$('modal-backdrop').onclick = closeSettings; + +function openSettings() { + $('s-url').value = cfg.siteUrl; + $('s-ck').value = cfg.ck; + $('s-cs').value = cfg.cs; + $('s-wp-user').value = cfg.wpUser || ''; + $('s-wp-pass').value = cfg.wpPass || ''; + $('s-wm-opacity').value = cfg.wmOpacity; + $('s-wm-size').value = cfg.wmSize; + $('s-bg-thresh').value = cfg.bgThresh; + $('s-max-dim').value = cfg.maxDimension || 1500; + $('wm-opacity-display').textContent = cfg.wmOpacity; + $('wm-size-display').textContent = cfg.wmSize; + $('bg-thresh-display').textContent = cfg.bgThresh; + $('max-dim-display').textContent = cfg.maxDimension || 1500; + if (cfg.wmDataUrl) $('wm-preview').src = cfg.wmDataUrl; + $('settings-modal').classList.remove('hidden'); +} +function closeSettings() { $('settings-modal').classList.add('hidden'); } + +$('s-wm-opacity').oninput = e => $('wm-opacity-display').textContent = e.target.value; +$('s-wm-size').oninput = e => $('wm-size-display').textContent = e.target.value; +$('s-bg-thresh').oninput = e => $('bg-thresh-display').textContent = e.target.value; +$('s-max-dim').oninput = e => $('max-dim-display').textContent = e.target.value; + +$('btn-save-settings').onclick = () => { + cfg.siteUrl = $('s-url').value.replace(/\/$/, ''); + cfg.ck = $('s-ck').value; + cfg.cs = $('s-cs').value; + cfg.wpUser = $('s-wp-user').value; + cfg.wpPass = $('s-wp-pass').value; + cfg.wmOpacity = parseInt($('s-wm-opacity').value); + cfg.wmSize = parseInt($('s-wm-size').value); + cfg.bgThresh = parseInt($('s-bg-thresh').value); + cfg.maxDimension = parseInt($('s-max-dim').value); + saveSettings(cfg); + closeSettings(); +}; + +$('btn-test-api').onclick = async () => { + const status = $('api-status'); + status.textContent = 'Testing…'; status.className = ''; + try { + const url = $('s-url').value.replace(/\/$/, ''); + const ck = $('s-ck').value, cs = $('s-cs').value; + const res = await fetch(`${url}/wp-json/wc/v3/products?per_page=1`, { + headers: { 'Authorization': 'Basic ' + btoa(ck + ':' + cs) } + }); + if (res.ok) { status.textContent = '✓ Connected successfully!'; status.className = 'ok'; } + else { status.textContent = `✕ HTTP ${res.status} — check credentials`; status.className = 'err'; } + } catch(e) { + status.textContent = `✕ ${e.message}`; + status.className = 'err'; + } +}; + +// Reveal password buttons +document.querySelectorAll('.btn-reveal').forEach(btn => { + btn.onclick = () => { + const inp = $(btn.dataset.target); + inp.type = inp.type === 'password' ? 'text' : 'password'; + }; +}); + +// Watermark image loader +$('btn-load-wm').onclick = () => $('input-wm').click(); +$('input-wm').onchange = e => { + const file = e.target.files[0]; if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + cfg.wmDataUrl = ev.target.result; + $('wm-preview').src = cfg.wmDataUrl; + loadWatermarkImg(cfg.wmDataUrl); + }; + reader.readAsDataURL(file); +}; + +function loadWatermarkImg(src) { + const img = new Image(); + img.onload = () => { state.watermarkImg = img; }; + img.src = src; +} + +// ── Init ─────────────────────────────────────────────────── +function init() { + // Load watermark: try saved data URL first, then watermark.png + if (cfg.wmDataUrl) { + loadWatermarkImg(cfg.wmDataUrl); + } else { + const img = new Image(); + img.onload = () => { state.watermarkImg = img; }; + img.onerror = () => { /* watermark.png not found — user can set in settings */ }; + img.src = 'watermark.png'; + } + initTooltips(); +} + +// ── Custom Tooltips ──────────────────────────────────────── +function initTooltips() { + const tt = document.createElement('div'); + tt.className = 'custom-tooltip'; + document.body.appendChild(tt); + + let timer; + const show = (el) => { + const text = el.getAttribute('title'); + if (!text) return; + el.dataset.title = text; + el.removeAttribute('title'); // Prevent default tooltip + + timer = setTimeout(() => { + tt.textContent = text; + tt.classList.add('visible'); + + const rect = el.getBoundingClientRect(); + let top = rect.top - tt.offsetHeight - 10; + tt.className = 'custom-tooltip visible pos-top'; + + if (top < 10) { // Flip to bottom if no space on top + top = rect.bottom + 10; + tt.className = 'custom-tooltip visible pos-bottom'; + } + + tt.style.top = top + 'px'; + tt.style.left = (rect.left + rect.width/2 - tt.offsetWidth/2) + 'px'; + }, 600); + }; + + const hide = (el) => { + clearTimeout(timer); + tt.classList.remove('visible'); + if (el.dataset.title) el.setAttribute('title', el.dataset.title); + }; + + document.body.addEventListener('mouseover', e => { + const el = e.target.closest('[title]'); + if (el) show(el); + }, true); + + document.body.addEventListener('mouseout', e => { + const el = e.target.closest('[data-title]'); + if (el) hide(el); + }, true); +} + +init(); + +$('btn-start-over').onclick = () => { + if (!confirm('Are you sure you want to start over? All imported and processed images will be cleared.')) return; + state.files = []; + state.processed = []; + $('import-preview').classList.add('hidden'); + $('drop-zone').style.display = ''; + $('input-files').value = ''; + $('input-folder').value = ''; + goStep(1); +}; + +function mapFinalToOriginal(rx, ry, W, H, bounds, nudge, cropPadding) { + const pad = Math.round(Math.max(bounds.width, bounds.height) * (cropPadding / 100)); + const side = Math.max(bounds.width, bounds.height) + pad * 2; + + const dx = Math.round((side - bounds.width) / 2) + (nudge ? nudge.x : 0); + const dy = Math.round((side - bounds.height) / 2) + (nudge ? nudge.y : 0); + + const xOrig = rx * side - dx + bounds.minX; + const yOrig = ry * side - dy + bounds.minY; + + return { + x: Math.max(0, Math.min(1, xOrig / W)), + y: Math.max(0, Math.min(1, yOrig / H)) + }; +} + +function mapOriginalToFinal(xPct, yPct, W, H, bounds, nudge, cropPadding) { + const pad = Math.round(Math.max(bounds.width, bounds.height) * (cropPadding / 100)); + const side = Math.max(bounds.width, bounds.height) + pad * 2; + + const dx = Math.round((side - bounds.width) / 2) + (nudge ? nudge.x : 0); + const dy = Math.round((side - bounds.height) / 2) + (nudge ? nudge.y : 0); + + const rx = (xPct * W - bounds.minX + dx) / side; + const ry = (yPct * H - bounds.minY + dy) / side; + + return { x: rx, y: ry }; +} + +// ── Editor Modal ─────────────────────────────────────────── +const edState = { + idx: null, + mode: 'move', // 'move' | 'crop' | 'lasso' | 'erase' | 'nudge' + wmX: 0, wmY: 0, + partX: 0, partY: 0, // Nudge offsets in pixels + box: null, // { x, y, w, h } as 0-1 + lassoPoints: null, // Array of { x, y } as 0-1 + activePath: null, // Array of { x, y } as 0-1 (current drawing lasso path) + eraseLassos: [], // Array of arrays of { x, y } as 0-1 + dragging: false, + panning: false, + zoom: 1.0, + panX: 0, + panY: 0, + ox: 0, oy: 0, sx: 0, sy: 0, + originalImg: null +}; + +function applyZoomPan() { + const container = $('ed-zoom-container'); + if (container) { + container.style.transform = `translate(${edState.panX}px, ${edState.panY}px) scale(${edState.zoom})`; + } +} + +function openEditor(i) { + const item = state.processed[i]; + edState.idx = i; + edState.mode = 'move'; + edState.box = item._manualMatte || null; + edState.lassoPoints = item._manualLasso || null; + edState.activePath = null; + edState.eraseLassos = item._eraseLassos ? [...item._eraseLassos] : []; + + edState.zoom = 1.0; + edState.panX = 0; + edState.panY = 0; + applyZoomPan(); + + // Re-attach handlers to be safe + if ($('ed-tool-move')) $('ed-tool-move').onclick = () => setTool('move'); + if ($('ed-tool-box')) $('ed-tool-box').onclick = () => setTool('crop'); + if ($('ed-tool-crop')) $('ed-tool-crop').onclick = () => setTool('crop'); + if ($('ed-tool-lasso')) $('ed-tool-lasso').onclick = () => setTool('lasso'); + if ($('ed-tool-erase')) $('ed-tool-erase').onclick = () => setTool('erase'); + if ($('ed-tool-nudge')) $('ed-tool-nudge').onclick = () => setTool('nudge'); + if ($('ed-clear-erase')) $('ed-clear-erase').onclick = () => { + edState.eraseLassos = []; + drawOverlay(); + edRender(); + }; + + edState.partX = item._partX || 0; + edState.partY = item._partY || 0; + edState.originalImg = null; + + $('ed-shadow').value = item._shadowIntensity || cfg.shadowIntensity || 22; + $('ed-shadow-val').textContent = $('ed-shadow').value; + + setTool('move'); + $('editor-filename').textContent = item.file.name; + $('ed-thresh').value = cfg.bgThresh; + $('ed-thresh-val').textContent = cfg.bgThresh; + $('ed-pad').value = item._cropPadding !== undefined ? item._cropPadding : 5; + $('ed-pad-val').textContent = $('ed-pad').value; + $('ed-wm-sz').value = cfg.wmSize; + $('ed-wm-sz-val').textContent = cfg.wmSize; + const edSolidBlack = $('ed-solid-black'); + if (edSolidBlack) { + edSolidBlack.checked = item._solidBlack !== undefined ? item._solidBlack : ($('toggle-silhouette') ? $('toggle-silhouette').checked : false); + } + + $('editor-modal').classList.remove('hidden'); + + const loader = $('ed-loader'); + if (loader) loader.classList.remove('hidden'); + + const img = new Image(); + img.onload = () => { + edState.originalImg = img; + if (loader) loader.classList.add('hidden'); + edRender(); + }; + img.src = item.originalUrl; +} + +function edRender() { + if (!edState.originalImg) return; + + const ec = $('ed-canvas'); + if (!ec) return; + const loader = $('ed-loader'); + if (loader) loader.classList.remove('hidden'); + + const item = state.processed[edState.idx]; + const removeBg = $('toggle-bg').checked; + const shadow = $('toggle-shadow').checked; + const crop = $('toggle-crop').checked; + const wmSzPct = parseInt($('ed-wm-sz').value); + const solidBlack = $('ed-solid-black') ? $('ed-solid-black').checked : false; + const shadowIntensity = parseInt($('ed-shadow').value); + const thresh = parseInt($('ed-thresh').value); + const padPct = parseInt($('ed-pad').value); + + // We ALWAYS show the final store-ready layout (crops, centering, shadow, watermark, etc.) + const finalCanvas = getProcessedCanvas(edState.originalImg, { + removeBg, + shadow, + wm: false, // We render the watermark as a draggable DOM element instead of burning it in, so the user can drag it! + crop, + manualMatte: edState.box, + manualLasso: edState.lassoPoints, + manualErases: edState.eraseLassos, + nudge: { x: edState.partX, y: edState.partY }, + shadowIntensity, + solidBlack, + bgThresh: thresh, + cropPadding: padPct + }); + + ec.width = finalCanvas.width; + ec.height = finalCanvas.height; + const ctx = ec.getContext('2d'); + ctx.clearRect(0, 0, ec.width, ec.height); + ctx.drawImage(finalCanvas, 0, 0); + + // Position and show watermark in move/nudge modes if enabled + edPositionWatermark(wmSzPct); + + // Update overlay size and clear it (we don't show crop/lasso overlays in move/nudge modes) + updateOverlayCanvasSize(); + drawOverlay(); + + if (loader) loader.classList.add('hidden'); +} + +// Returns canvas position/size relative to the zoom container parent +function getCanvasOffset() { + const canvas = $('ed-canvas'); + if (!canvas) return { left: 0, top: 0, displayW: 0, displayH: 0 }; + return { + left: canvas.offsetLeft, + top: canvas.offsetTop, + displayW: canvas.offsetWidth, + displayH: canvas.offsetHeight, + }; +} + +function edPositionWatermark(wmSzPct) { + if (!state.watermarkImg) return; + const ec = $('ed-canvas'); + const wm = $('ed-wm-img'); + if (cfg.wmDataUrl) wm.src = cfg.wmDataUrl; + else wm.src = state.watermarkImg.src || state.watermarkImg.currentSrc || ''; + + const off = getCanvasOffset(); + const scale = off.displayW / ec.width || 1; + const wmPx = Math.round(ec.width * (wmSzPct / 100)); + const ratio = state.watermarkImg.naturalHeight / state.watermarkImg.naturalWidth; + const wmHpx = Math.round(wmPx * ratio); + + wm.style.width = Math.round(wmPx * scale) + 'px'; + wm.style.height = 'auto'; + wm.style.opacity = cfg.wmOpacity / 100; + wm.style.display = $('toggle-watermark').checked ? '' : 'none'; + + // Default: bottom-right corner of the canvas (not the wrap) + if (edState.wmX === 0 && edState.wmY === 0) { + edState.wmX = off.displayW - Math.round(wmPx * scale) - 10; + edState.wmY = off.displayH - Math.round(wmHpx * scale) - 10; + } + + // wmX/wmY are canvas-relative display pixels → offset by canvas position in zoom container + wm.style.left = (off.left + edState.wmX) + 'px'; + wm.style.top = (off.top + edState.wmY) + 'px'; +} + +// Tool switching +function setTool(mode) { + edState.mode = mode; + const btnMove = $('ed-tool-move'); + const btnBox = $('ed-tool-box') || $('ed-tool-crop'); + const btnLasso = $('ed-tool-lasso'); + const btnErase = $('ed-tool-erase'); + const btnNudge = $('ed-tool-nudge'); + if (btnMove) btnMove.classList.toggle('active', mode === 'move'); + if (btnBox) btnBox.classList.toggle('active', mode === 'crop'); + if (btnLasso) btnLasso.classList.toggle('active', mode === 'lasso'); + if (btnErase) btnErase.classList.toggle('active', mode === 'erase'); + if (btnNudge) btnNudge.classList.toggle('active', mode === 'nudge'); + + const wrap = $('ed-preview-wrap'); + if (wrap) { + wrap.classList.toggle('mode-move', mode === 'move'); + wrap.classList.toggle('mode-crop', mode === 'crop'); + wrap.classList.toggle('mode-lasso', mode === 'lasso'); + wrap.classList.toggle('mode-erase', mode === 'erase'); + wrap.classList.toggle('mode-nudge', mode === 'nudge'); + } + + $('ed-wm-img').style.pointerEvents = mode === 'move' ? 'auto' : 'none'; + $('ed-selection-box').classList.add('hidden'); + + // Re-render immediately to update canvas view (raw original vs final output) + edRender(); +} + +function updateOverlayCanvasSize() { + const ec = $('ed-canvas'); + const oc = $('ed-overlay-canvas'); + if (!ec || !oc) return; + oc.width = ec.width; + oc.height = ec.height; + oc.style.left = ec.offsetLeft + 'px'; + oc.style.top = ec.offsetTop + 'px'; + oc.style.width = ec.offsetWidth + 'px'; + oc.style.height = ec.offsetHeight + 'px'; +} + +function drawOverlay() { + const oc = $('ed-overlay-canvas'); + if (!oc) return; + const ctx = oc.getContext('2d'); + ctx.clearRect(0, 0, oc.width, oc.height); + + const W = oc.width, H = oc.height; + + // Projection helper: + const project = (xPct, yPct) => { + if (edState.renderBounds && edState.originalImg) { + const origW = edState.originalImg.naturalWidth; + const origH = edState.originalImg.naturalHeight; + const pt = mapOriginalToFinal(xPct, yPct, origW, origH, edState.renderBounds, edState.renderNudge, edState.renderCropPadding); + return { x: pt.x * W, y: pt.y * H }; + } + return { x: xPct * W, y: yPct * H }; + }; + + // 1. Draw completed Erase Lassos (Red) + if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { + if (edState.eraseLassos && edState.eraseLassos.length > 0) { + for (const pts of edState.eraseLassos) { + if (pts.length > 2) { + ctx.beginPath(); + const p0 = project(pts[0].x, pts[0].y); + ctx.moveTo(p0.x, p0.y); + for (let i = 1; i < pts.length; i++) { + const p = project(pts[i].x, pts[i].y); + ctx.lineTo(p.x, p.y); + } + ctx.closePath(); + ctx.fillStyle = 'rgba(235, 87, 87, 0.2)'; + ctx.fill(); + ctx.strokeStyle = '#eb5757'; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.setLineDash([]); + } + } + } + } + + // 2. Draw Keep Box outline (if it exists) + if (edState.box) { + const { x, y, w, h } = edState.box; + const p1 = project(x, y); + const p2 = project(x + w, y + h); + ctx.strokeStyle = '#3b7dd8'; + ctx.lineWidth = Math.max(2, Math.round(W * 0.002)); + ctx.setLineDash([6, 4]); + ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y); + ctx.setLineDash([]); + } + + // 3. Draw Keep Lasso outline (if it exists) + const lassoPts = (edState.mode === 'lasso' && edState.activePath) ? edState.activePath : edState.lassoPoints; + if (lassoPts && lassoPts.length > 0) { + ctx.beginPath(); + const p0 = project(lassoPts[0].x, lassoPts[0].y); + ctx.moveTo(p0.x, p0.y); + for (let i = 1; i < lassoPts.length; i++) { + const p = project(lassoPts[i].x, lassoPts[i].y); + ctx.lineTo(p.x, p.y); + } + ctx.closePath(); + ctx.strokeStyle = '#3b7dd8'; + ctx.lineWidth = Math.max(2, Math.round(W * 0.002)); + ctx.setLineDash([6, 4]); + ctx.stroke(); + ctx.setLineDash([]); + + // Draw dots + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#3b7dd8'; + ctx.lineWidth = 1; + const dotRadius = Math.max(3, Math.round(W * 0.003)); + const step = edState.dragging ? 15 : 1; + for (let i = 0; i < lassoPts.length; i += step) { + const p = project(lassoPts[i].x, lassoPts[i].y); + ctx.beginPath(); + ctx.arc(p.x, p.y, dotRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + if ((lassoPts.length - 1) % step !== 0) { + const lastIdx = lassoPts.length - 1; + const p = project(lassoPts[lastIdx].x, lassoPts[lastIdx].y); + ctx.beginPath(); + ctx.arc(p.x, p.y, dotRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + + // 4. Draw Active Erase Lasso Drawing outline (Red) + if (edState.mode === 'erase') { + const pts = edState.activePath; + if (pts && pts.length > 0) { + ctx.beginPath(); + const p0 = project(pts[0].x, pts[0].y); + ctx.moveTo(p0.x, p0.y); + for (let i = 1; i < pts.length; i++) { + const p = project(pts[i].x, pts[i].y); + ctx.lineTo(p.x, p.y); + } + ctx.strokeStyle = '#eb5757'; + ctx.lineWidth = 2.5; + ctx.stroke(); + + // Draw dots + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#eb5757'; + ctx.lineWidth = 1; + const dotRadius = 4; + for (let i = 0; i < pts.length; i += 15) { + const p = project(pts[i].x, pts[i].y); + ctx.beginPath(); + ctx.arc(p.x, p.y, dotRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + } +} + +function getCanvasCoords(e) { + const canvas = $('ed-canvas'); + if (!canvas) return { x: 0, y: 0 }; + const canvRect = canvas.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + + const rx = (clientX - canvRect.left) / canvRect.width; + const ry = (clientY - canvRect.top) / canvRect.height; + + // Map final canvas coordinate back to original image coordinate if we have render metrics + if (edState.renderBounds && edState.originalImg) { + const W = edState.originalImg.naturalWidth; + const H = edState.originalImg.naturalHeight; + return mapFinalToOriginal(rx, ry, W, H, edState.renderBounds, edState.renderNudge, edState.renderCropPadding); + } + + return { + x: Math.max(0, Math.min(1, rx)), + y: Math.max(0, Math.min(1, ry)) + }; +} + +// Drag / Draw events +const edWrap = $('ed-preview-wrap'); +edWrap.addEventListener('mousedown', e => { + if (e.target.id === 'ed-wm-img') return; + + if (e.button === 1) { // Middle mouse button (Mouse 3) + e.preventDefault(); + edState.panning = true; + edState.panStartX = e.clientX; + edState.panStartY = e.clientY; + edState.panStartPanX = edState.panX; + edState.panStartPanY = edState.panY; + return; + } + + if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { + edState.lockedBounds = edState.renderBounds || { minX: 0, minY: 0, width: edState.originalImg.naturalWidth, height: edState.originalImg.naturalHeight }; + } + + const coords = getCanvasCoords(e); + edState.dragging = true; + edState.ox = e.clientX; edState.oy = e.clientY; + + if (edState.mode === 'crop') { + edState.sx = coords.x; + edState.sy = coords.y; + edState.box = { x: coords.x, y: coords.y, w: 0, h: 0 }; + updateOverlayCanvasSize(); + drawOverlay(); + } else if (edState.mode === 'lasso' || edState.mode === 'erase') { + edState.activePath = [{ x: coords.x, y: coords.y }]; + updateOverlayCanvasSize(); + drawOverlay(); + } else if (edState.mode === 'nudge') { + edState.sx = edState.partX; + edState.sy = edState.partY; + } +}); + +edWrap.addEventListener('touchstart', e => { + if (e.target.id === 'ed-wm-img') return; + + if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { + edState.lockedBounds = edState.renderBounds || { minX: 0, minY: 0, width: edState.originalImg.naturalWidth, height: edState.originalImg.naturalHeight }; + } + + const coords = getCanvasCoords(e); + edState.dragging = true; + edState.ox = e.touches[0].clientX; edState.oy = e.touches[0].clientY; + + if (edState.mode === 'crop') { + edState.sx = coords.x; + edState.sy = coords.y; + edState.box = { x: coords.x, y: coords.y, w: 0, h: 0 }; + updateOverlayCanvasSize(); + drawOverlay(); + } else if (edState.mode === 'lasso' || edState.mode === 'erase') { + edState.activePath = [{ x: coords.x, y: coords.y }]; + updateOverlayCanvasSize(); + drawOverlay(); + } else if (edState.mode === 'nudge') { + edState.sx = edState.partX; + edState.sy = edState.partY; + } +}); + +document.addEventListener('mousemove', e => { + if (edState.panning) { + edState.panX = edState.panStartPanX + (e.clientX - edState.panStartX); + edState.panY = edState.panStartPanY + (e.clientY - edState.panStartY); + applyZoomPan(); + return; + } + + if (!edState.dragging) return; + + if (edState.mode === 'move') { + const off = getCanvasOffset(); + const wm = $('ed-wm-img'); + edState.wmX = Math.max(0, Math.min(off.displayW - wm.offsetWidth, edState.sx + (e.clientX - edState.ox) / edState.zoom)); + edState.wmY = Math.max(0, Math.min(off.displayH - wm.offsetHeight, edState.sy + (e.clientY - edState.oy) / edState.zoom)); + wm.style.left = (off.left + edState.wmX) + 'px'; + wm.style.top = (off.top + edState.wmY) + 'px'; + } else { + const coords = getCanvasCoords(e); + if (edState.mode === 'crop') { + edState.box = { + x: Math.min(edState.sx, coords.x), + y: Math.min(edState.sy, coords.y), + w: Math.abs(coords.x - edState.sx), + h: Math.abs(coords.y - edState.sy) + }; + drawOverlay(); + } else if (edState.mode === 'lasso' || edState.mode === 'erase') { + const pts = edState.activePath; + if (pts) { + const lastPt = pts[pts.length - 1]; + const dist = Math.hypot(coords.x - lastPt.x, coords.y - lastPt.y); + if (dist > 0.005) { + pts.push({ x: coords.x, y: coords.y }); + drawOverlay(); + } + } + } else if (edState.mode === 'nudge') { + const ec = $('ed-canvas'); + const off = getCanvasOffset(); + const scale = ec.width / off.displayW; + edState.partX = edState.sx + ((e.clientX - edState.ox) / edState.zoom) * scale; + edState.partY = edState.sy + ((e.clientY - edState.oy) / edState.zoom) * scale; + const tx = (e.clientX - edState.ox) / edState.zoom; + const ty = (e.clientY - edState.oy) / edState.zoom; + ec.style.transform = `translate(${tx}px, ${ty}px)`; + } + } +}); + +document.addEventListener('touchmove', e => { + if (!edState.dragging) return; + const clientX = e.touches[0].clientX; + const clientY = e.touches[0].clientY; + + if (edState.mode === 'move') { + const off = getCanvasOffset(); + const wm = $('ed-wm-img'); + edState.wmX = Math.max(0, Math.min(off.displayW - wm.offsetWidth, edState.sx + (clientX - edState.ox) / edState.zoom)); + edState.wmY = Math.max(0, Math.min(off.displayH - wm.offsetHeight, edState.sy + (clientY - edState.oy) / edState.zoom)); + wm.style.left = (off.left + edState.wmX) + 'px'; + wm.style.top = (off.top + edState.wmY) + 'px'; + } else { + const coords = getCanvasCoords(e); + if (edState.mode === 'crop') { + edState.box = { + x: Math.min(edState.sx, coords.x), + y: Math.min(edState.sy, coords.y), + w: Math.abs(coords.x - edState.sx), + h: Math.abs(coords.y - edState.sy) + }; + drawOverlay(); + } else if (edState.mode === 'lasso' || edState.mode === 'erase') { + const pts = edState.activePath; + if (pts) { + const lastPt = pts[pts.length - 1]; + const dist = Math.hypot(coords.x - lastPt.x, coords.y - lastPt.y); + if (dist > 0.005) { + pts.push({ x: coords.x, y: coords.y }); + drawOverlay(); + } + } + } else if (edState.mode === 'nudge') { + const ec = $('ed-canvas'); + const off = getCanvasOffset(); + const scale = ec.width / off.displayW; + edState.partX = edState.sx + ((clientX - edState.ox) / edState.zoom) * scale; + edState.partY = edState.sy + ((clientY - edState.oy) / edState.zoom) * scale; + const tx = (clientX - edState.ox) / edState.zoom; + const ty = (clientY - edState.oy) / edState.zoom; + ec.style.transform = `translate(${tx}px, ${ty}px)`; + } + } +}, { passive: true }); + +document.addEventListener('mouseup', () => { + if (edState.panning) { + edState.panning = false; + return; + } + + if (edState.dragging) { + edState.lockedBounds = null; + if (edState.mode === 'nudge') { + $('ed-canvas').style.transform = ''; + edRender(); + } else if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { + if (edState.mode === 'lasso') { + if (edState.activePath && edState.activePath.length > 2) { + edState.lassoPoints = edState.activePath; + } + edState.activePath = null; + } else if (edState.mode === 'erase') { + if (edState.activePath && edState.activePath.length > 2) { + edState.eraseLassos.push(edState.activePath); + } + edState.activePath = null; + } + edRender(); + } + } + edState.dragging = false; +}); + +document.addEventListener('touchend', () => { + if (edState.dragging) { + edState.lockedBounds = null; + if (edState.mode === 'nudge') { + $('ed-canvas').style.transform = ''; + edRender(); + } else if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { + if (edState.mode === 'lasso') { + if (edState.activePath && edState.activePath.length > 2) { + edState.lassoPoints = edState.activePath; + } + edState.activePath = null; + } else if (edState.mode === 'erase') { + if (edState.activePath && edState.activePath.length > 2) { + edState.eraseLassos.push(edState.activePath); + } + edState.activePath = null; + } + edRender(); + } + } + edState.dragging = false; +}); + +const edWm = $('ed-wm-img'); +edWm.addEventListener('mousedown', e => { + if (edState.mode !== 'move') return; + e.preventDefault(); + edState.dragging = true; + edState.ox = e.clientX; edState.oy = e.clientY; + edState.sx = edState.wmX; edState.sy = edState.wmY; +}); + +edWm.addEventListener('touchstart', e => { + if (edState.mode !== 'move') return; + edState.dragging = true; + edState.ox = e.touches[0].clientX; edState.oy = e.touches[0].clientY; + edState.sx = edState.wmX; edState.sy = edState.wmY; +}); + +// Scroll wheel zoom on preview wrapper +edWrap.addEventListener('wheel', e => { + e.preventDefault(); + const rect = edWrap.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Mouse coordinate relative to the zoom container before zoom level changes: + const cx = (mouseX - edState.panX) / edState.zoom; + const cy = (mouseY - edState.panY) / edState.zoom; + + const zoomFactor = 1.1; + if (e.deltaY < 0) { + edState.zoom = Math.min(10, edState.zoom * zoomFactor); + } else { + edState.zoom = Math.max(0.5, edState.zoom / zoomFactor); + } + + // Shift pan offsets to align zoom center with cursor position: + edState.panX = mouseX - cx * edState.zoom; + edState.panY = mouseY - cy * edState.zoom; + + applyZoomPan(); +}, { passive: false }); + +window.addEventListener('resize', () => { + if (!$('editor-modal').classList.contains('hidden')) { + updateOverlayCanvasSize(); + drawOverlay(); + const wmSzPct = parseInt($('ed-wm-sz').value); + edPositionWatermark(wmSzPct); + } +}); + +// Sidebar slider live updates +$('ed-thresh').oninput = e => { $('ed-thresh-val').textContent = e.target.value; edRender(); }; +$('ed-pad').oninput = e => { $('ed-pad-val').textContent = e.target.value; edRender(); }; +$('ed-wm-sz').oninput = e => { $('ed-wm-sz-val').textContent = e.target.value; edPositionWatermark(parseInt(e.target.value)); }; +$('ed-shadow').oninput = e => { $('ed-shadow-val').textContent = e.target.value; edRender(); }; +$('ed-white-bg').onchange = e => $('ed-preview-wrap').classList.toggle('preview-white', e.target.checked); +const edSolidBlack = $('ed-solid-black'); +if (edSolidBlack) { + edSolidBlack.onchange = e => { + edRender(); + }; +} + +$('ed-reset').onclick = () => { + if (!confirm('Are you sure you want to discard all manual changes for this image?')) return; + edState.wmX = 0; edState.wmY = 0; + edState.partX = 0; edState.partY = 0; + edState.box = null; + edState.lassoPoints = null; + edState.eraseLassos = []; + + edState.zoom = 1.0; + edState.panX = 0; + edState.panY = 0; + applyZoomPan(); + + $('ed-thresh').value = cfg.bgThresh; + $('ed-thresh-val').textContent = cfg.bgThresh; + $('ed-pad').value = 5; + $('ed-pad-val').textContent = 5; + $('ed-wm-sz').value = cfg.wmSize; + $('ed-wm-sz-val').textContent = cfg.wmSize; + $('ed-shadow').value = cfg.shadowIntensity || 22; + $('ed-shadow-val').textContent = $('ed-shadow').value; + const edSolidBlack = $('ed-solid-black'); + if (edSolidBlack) { + edSolidBlack.checked = cfg.solidBlack || false; + } + + edRender(); +}; + +$('editor-backdrop').onclick = () => $('editor-modal').classList.add('hidden'); +$('ed-close-btn').onclick = () => $('editor-modal').classList.add('hidden'); + +$('ed-apply').onclick = async () => { + const item = state.processed[edState.idx]; + const thresh = parseInt($('ed-thresh').value); + const wmSzPct = parseInt($('ed-wm-sz').value); + const quality = parseInt($('quality-slider').value) / 100; + const removeBg = $('toggle-bg').checked; + const shadow = $('toggle-shadow').checked; + const crop = $('toggle-crop').checked; + const wm = $('toggle-watermark').checked; + const shadowIntensity = parseInt($('ed-shadow').value); + const solidBlack = $('ed-solid-black') ? $('ed-solid-black').checked : false; + const padPct = parseInt($('ed-pad').value); + + // Convert canvas-relative display pixels → percentage of canvas size (0 to 1) + const off = getCanvasOffset(); + const wmXpct = edState.wmX / off.displayW; + const wmYpct = edState.wmY / off.displayH; + + const savedThresh = cfg.bgThresh; + const savedWmSize = cfg.wmSize; + cfg.bgThresh = thresh; + cfg.wmSize = wmSzPct; + + item._wmPos = { xPct: wmXpct, yPct: wmYpct }; + item._manualMatte = edState.box; + item._manualLasso = edState.lassoPoints; + item._eraseLassos = edState.eraseLassos; + item._partX = edState.partX; + item._partY = edState.partY; + item._shadowIntensity = shadowIntensity; + item._solidBlack = solidBlack; + item._cropPadding = padPct; + + item.blob = await processImage(item.file, { + quality, removeBg, shadow, wm, crop, + manualMatte: edState.box, + manualLasso: edState.lassoPoints, + manualErases: edState.eraseLassos, + nudge: { x: edState.partX, y: edState.partY }, + shadowIntensity, + solidBlack, + cropPadding: padPct, + wmPos: item._wmPos + }); + + cfg.bgThresh = savedThresh; + cfg.wmSize = savedWmSize; + + item.processedUrl = URL.createObjectURL(item.blob); + $(`proc-${edState.idx}`).src = item.processedUrl; + setStatus(edState.idx, 'done', `Edited — ${(item.blob.size/1024).toFixed(0)} KB`); + $('editor-modal').classList.add('hidden'); +}; + +// Keyboard navigation for editor modal +document.addEventListener('keydown', e => { + if ($('editor-modal').classList.contains('hidden')) return; + + if (e.key === 'Enter') { + if (e.target.tagName === 'INPUT' && e.target.type === 'text') return; + + e.preventDefault(); + if (edState.mode === 'crop' || edState.mode === 'lasso' || edState.mode === 'erase') { + setTool('move'); + } else { + $('ed-apply').click(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + $('ed-close-btn').click(); + } +}); + diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..d321157 --- /dev/null +++ b/start.bat @@ -0,0 +1,88 @@ +@echo off +title CSG Image Upload Tool +color 0B +echo. +echo ========================================== +echo CSG Image Upload Tool ^| Launcher +echo ========================================== +echo. + +set PORT=3000 + +:: Change to the folder where this bat file lives +cd /d "%~dp0" + +echo Looking for a web server on your machine... +echo. + +:: ── Try Python 3 (most common) ───────────────────────────── +python --version >nul 2>&1 +if %errorlevel% == 0 ( + echo [OK] Found Python! Starting on http://localhost:%PORT% + echo. + echo Leave this window open while using the tool. + echo Press Ctrl+C here to stop the server when done. + echo. + timeout /t 2 /nobreak >nul + start "" "http://localhost:%PORT%" + python -m http.server %PORT% + goto :end +) + +:: ── Try "py" launcher (Windows Python Launcher) ──────────── +py -3 --version >nul 2>&1 +if %errorlevel% == 0 ( + echo [OK] Found Python (py launcher)! Starting on http://localhost:%PORT% + echo. + echo Leave this window open while using the tool. + echo Press Ctrl+C here to stop the server when done. + echo. + timeout /t 2 /nobreak >nul + start "" "http://localhost:%PORT%" + py -3 -m http.server %PORT% + goto :end +) + +:: ── Try Node.js / npx ────────────────────────────────────── +npx --version >nul 2>&1 +if %errorlevel% == 0 ( + echo [OK] Found Node.js! Starting on http://localhost:%PORT% + echo. + echo Leave this window open while using the tool. + echo Press Ctrl+C here to stop the server when done. + echo. + timeout /t 2 /nobreak >nul + start "" "http://localhost:%PORT%" + npx -y serve . -l %PORT% + goto :end +) + +:: ── Nothing found ────────────────────────────────────────── +color 0C +echo. +echo ========================================== +echo No web server found on your machine! +echo ========================================== +echo. +echo This tool requires a local web server because +echo browsers block camera + API access from files +echo opened directly (file://). +echo. +echo Fix: Install Python (free, easy) then try again: +echo. +echo https://www.python.org/downloads/ +echo. +echo During install, CHECK the box: +echo "Add Python to PATH" +echo. +echo Then double-click start.bat again. +echo. +echo ── OR install Node.js: https://nodejs.org/ ── +echo. +pause +goto :eof + +:end +echo. +echo Server stopped. +pause diff --git a/style.css b/style.css new file mode 100644 index 0000000..edc6578 --- /dev/null +++ b/style.css @@ -0,0 +1,604 @@ +/* ─── Reset & Base ─────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-deep: #080d18; + --bg-card: rgba(255,255,255,0.04); + --bg-card2: rgba(255,255,255,0.07); + --border: rgba(255,255,255,0.08); + --border2: rgba(255,255,255,0.14); + --accent1: #3b7dd8; + --accent2: #6c5ce7; + --accent-g: linear-gradient(135deg, #3b7dd8, #6c5ce7); + --success: #00c896; + --warning: #f4a535; + --danger: #e05c5c; + --text: #e8eaf0; + --text-muted: #7a82a0; + --text-dim: #4a5270; + --radius: 12px; + --radius-sm: 8px; + --shadow-lg: 0 24px 48px rgba(0,0,0,0.5); + --shadow-md: 0 8px 24px rgba(0,0,0,0.35); +} + +html { scroll-behavior: smooth; } + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg-deep); + color: var(--text); + min-height: 100vh; + font-size: 14px; + line-height: 1.6; + background-image: + radial-gradient(ellipse 60% 40% at 20% 0%, rgba(59,125,216,0.12) 0%, transparent 60%), + radial-gradient(ellipse 50% 40% at 80% 100%, rgba(108,92,231,0.1) 0%, transparent 60%); +} + +/* ─── Header ───────────────────────────────────────────────────────────── */ +#app-header { + position: sticky; top: 0; z-index: 100; + background: rgba(8,13,24,0.85); + backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); +} +.header-inner { + max-width: 1200px; margin: 0 auto; + padding: 12px 24px; + display: flex; align-items: center; justify-content: space-between; +} +.header-brand { + display: flex; align-items: center; gap: 14px; +} +.header-brand img { + height: 44px; width: auto; + filter: drop-shadow(0 2px 8px rgba(59,125,216,0.4)); +} +.header-brand h1 { + font-size: 18px; font-weight: 700; + background: var(--accent-g); -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.header-brand p { font-size: 11px; color: var(--text-muted); } + +/* ─── Step Nav ─────────────────────────────────────────────────────────── */ +#steps-nav { + display: flex; align-items: center; justify-content: center; + gap: 0; padding: 20px 24px; + max-width: 600px; margin: 0 auto; +} +.step { + display: flex; flex-direction: column; align-items: center; gap: 6px; + cursor: default; transition: opacity 0.3s; +} +.step-num { + width: 36px; height: 36px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 14px; + background: var(--bg-card); border: 2px solid var(--border2); + color: var(--text-muted); + transition: all 0.3s; +} +.step span { font-size: 11px; font-weight: 500; color: var(--text-muted); transition: color 0.3s; } +.step.active .step-num { + background: var(--accent-g); border-color: transparent; + color: #fff; box-shadow: 0 0 20px rgba(59,125,216,0.5); +} +.step.active span { color: var(--text); } +.step.done .step-num { background: var(--success); border-color: transparent; color: #fff; } +.step.done span { color: var(--success); } +.step-connector { + flex: 1; height: 2px; margin: 0 8px; margin-bottom: 22px; + background: var(--border2); + transition: background 0.3s; +} +.step-connector.done { background: var(--success); } + +/* ─── Main / Panels ────────────────────────────────────────────────────── */ +#app-main { max-width: 1200px; margin: 0 auto; padding: 0 24px 80px; } +.step-panel { display: none; animation: fadeIn 0.35s ease; } +.step-panel.active { display: block; } +@keyframes fadeIn { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:none; } } + +.panel-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 24px; +} +.panel-header h2 { font-size: 22px; font-weight: 700; } + +/* ─── Mode Toggle ──────────────────────────────────────────────────────── */ +.mode-toggle { + display: flex; background: var(--bg-card); border: 1px solid var(--border); + border-radius: 8px; overflow: hidden; +} +.mode-btn { + padding: 6px 18px; border: none; background: none; + color: var(--text-muted); font-size: 13px; font-weight: 500; + cursor: pointer; transition: all 0.2s; +} +.mode-btn.active { background: var(--accent-g); color: #fff; } + +/* ─── Drop Zone ────────────────────────────────────────────────────────── */ +#drop-zone { + border: 2px dashed var(--border2); + border-radius: var(--radius); + background: var(--bg-card); + transition: all 0.25s; + margin-bottom: 24px; +} +#drop-zone.drag-over { + border-color: var(--accent1); + background: rgba(59,125,216,0.08); +} +.drop-zone-inner { + display: flex; flex-direction: column; align-items: center; + padding: 64px 32px; gap: 12px; text-align: center; +} +.drop-icon svg { width: 64px; height: 64px; stroke: var(--text-dim); } +.drop-zone-inner h3 { font-size: 18px; font-weight: 600; } +.drop-zone-inner p { color: var(--text-muted); font-size: 13px; } +.drop-actions { display: flex; gap: 12px; margin-top: 8px; } + +/* ─── Import Preview ───────────────────────────────────────────────────── */ +.preview-toolbar { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 16px; +} +.preview-toolbar span { font-weight: 600; color: var(--accent1); } + +.thumb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 10px; margin-bottom: 24px; +} +.thumb-item { + aspect-ratio: 1; border-radius: var(--radius-sm); + overflow: hidden; background: var(--bg-card); + border: 1px solid var(--border); position: relative; +} +.thumb-item img { width: 100%; height: 100%; object-fit: cover; } +.thumb-item .thumb-name { + position: absolute; bottom: 0; left: 0; right: 0; + padding: 3px 5px; font-size: 9px; color: #fff; + background: rgba(0,0,0,0.6); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; +} + +/* ─── Options Bar ──────────────────────────────────────────────────────── */ +.options-bar { + display: flex; flex-wrap: wrap; gap: 20px; align-items: center; + padding: 16px 20px; margin-bottom: 24px; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); +} +.option-group { display: flex; flex-direction: column; gap: 6px; } +.option-group label { font-size: 12px; font-weight: 500; color: var(--text-muted); } + +input[type="range"] { + -webkit-appearance: none; appearance: none; + width: 140px; height: 4px; + background: var(--border2); border-radius: 4px; outline: none; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; appearance: none; + width: 16px; height: 16px; border-radius: 50%; + background: var(--accent1); cursor: pointer; + box-shadow: 0 0 8px rgba(59,125,216,0.5); +} + +/* Toggle */ +.toggle-label { + display: flex; align-items: center; gap: 10px; + cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text); + user-select: none; white-space: nowrap; +} +.toggle-label input[type="checkbox"] { display: none; } +.toggle-track { + width: 40px; height: 22px; border-radius: 11px; + background: #333a50; position: relative; + transition: background 0.2s; flex-shrink: 0; + border: 1px solid rgba(255,255,255,0.1); +} +.toggle-track::after { + content: ''; position: absolute; + width: 16px; height: 16px; border-radius: 50%; + background: #fff; top: 3px; left: 3px; + transition: transform 0.2s; +} +.toggle-label input:checked + .toggle-track { background: var(--accent1); } +.toggle-label input:checked + .toggle-track::after { transform: translateX(18px); } + +/* ─── Process Grid ─────────────────────────────────────────────────────── */ +.process-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 16px; margin-bottom: 24px; +} +.process-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; + transition: border-color 0.2s, transform 0.2s; +} +.process-card:hover { border-color: var(--border2); transform: translateY(-2px); } +.process-card-images { + display: grid; grid-template-columns: 1fr 1fr; + height: 240px; +} +.process-card-images .img-pane { + position: relative; overflow: hidden; background: #1a1a2e; +} +.process-card-images .img-pane img { + width: 100%; height: 100%; object-fit: contain; +} +.process-card-images .img-pane .pane-label { + position: absolute; top: 6px; left: 6px; + font-size: 9px; font-weight: 700; text-transform: uppercase; + padding: 2px 6px; border-radius: 4px; + background: rgba(0,0,0,0.6); color: var(--text-muted); + letter-spacing: 0.5px; +} +.process-card-images .img-divider { width: 1px; background: var(--border); } +.process-card-body { padding: 12px 14px; } +.process-card-name { + font-size: 11px; color: var(--text-muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-bottom: 8px; +} +.process-card-actions { display: flex; gap: 8px; } +.process-status { + display: flex; align-items: center; gap: 6px; + font-size: 12px; margin-top: 8px; min-height: 20px; +} +.status-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--text-dim); flex-shrink: 0; +} +.status-dot.processing { background: var(--warning); animation: pulse 1s infinite; } +.status-dot.done { background: var(--success); } +.status-dot.error { background: var(--danger); } +@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.3; } } + +/* ─── Editor Modal ─────────────────────────────────────────────────────── */ +.editor-box { + position: relative; z-index: 1; + width: 96vw; max-width: 1100px; + background: #0e1528; border: 1px solid var(--border2); + border-radius: 16px; box-shadow: var(--shadow-lg); + display: flex; flex-direction: column; + max-height: 95vh; overflow: hidden; + animation: slideUp 0.3s ease; +} +.editor-top { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 20px; border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.editor-top span { font-size: 13px; font-weight: 600; color: var(--text-muted); } +.editor-top-actions { display: flex; gap: 8px; } +.editor-body { + display: grid; grid-template-columns: 1fr 220px; + flex: 1; overflow: hidden; +} +.editor-preview-wrap { + position: relative; overflow: hidden; + background: repeating-conic-gradient(#1a1a2e 0% 25%, #232840 0% 50%) 0 0 / 24px 24px; + display: flex; align-items: center; justify-content: center; +} +.editor-preview-wrap.preview-white { background: #fff !important; } +.editor-preview-wrap canvas { + max-width: 100%; max-height: calc(95vh - 120px); + object-fit: contain; display: block; +} +.ed-wm-draggable { + position: absolute; + cursor: grab; user-select: none; + filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); + touch-action: none; +} +.ed-wm-draggable:active { cursor: grabbing; } +.editor-sidebar { + padding: 16px; border-left: 1px solid var(--border); + display: flex; flex-direction: column; gap: 12px; + overflow-y: auto; background: rgba(0,0,0,0.2); +} +.editor-sidebar .form-group { margin-bottom: 0; } +.editor-sidebar .toggle-label { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: rgba(255,255,255,0.03); + border: 1px solid var(--border); + border-radius: 8px; + margin: 4px 0; + width: 100%; + box-sizing: border-box; +} +.editor-sidebar .toggle-label:hover { + background: rgba(255,255,255,0.06); +} +.editor-tool-group { + display: flex; gap: 8px; margin-bottom: 16px; +} +.btn-tool { + flex: 1; background: var(--bg-card); border: 1px solid var(--border); + color: var(--text-muted); padding: 10px; border-radius: 8px; + font-size: 18px; cursor: pointer; transition: all 0.2s; +} +.btn-tool:hover { background: var(--border); } +.btn-tool.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); + box-shadow: 0 0 12px rgba(59, 125, 216, 0.4); +} + +.editor-preview-wrap.mode-crop { cursor: crosshair; } +.editor-preview-wrap.mode-lasso { cursor: crosshair; } +.editor-preview-wrap.mode-erase { cursor: crosshair; } +.editor-preview-wrap.mode-move { cursor: default; } +.editor-preview-wrap.mode-nudge { cursor: move; } + +#ed-zoom-container { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + transform-origin: 0 0; + display: flex; + align-items: center; + justify-content: center; +} + +.selection-box { + position: absolute; border: 2px dashed var(--accent); + background: rgba(59, 125, 216, 0.15); pointer-events: none; + z-index: 10; +} +.editor-sidebar label { font-size: 12px; color: var(--text-muted); display: block; margin-bottom: 6px; } +.editor-sidebar input[type="range"] { width: 100%; } +.editor-hint { + font-size: 11px; color: var(--text-dim); text-align: center; + margin-top: auto; padding-top: 12px; line-height: 1.5; +} + + +/* ─── Upload Grid ──────────────────────────────────────────────────────── */ +.upload-grid { + display: flex; flex-direction: column; gap: 12px; + margin-bottom: 24px; +} +.upload-row { + display: grid; + grid-template-columns: 80px 1fr 200px 160px auto; + gap: 16px; align-items: center; + padding: 12px 16px; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); + transition: border-color 0.2s; +} +.upload-row:hover { border-color: var(--border2); } +.upload-row img { width: 80px; height: 80px; object-fit: contain; border-radius: var(--radius-sm); background: #1a1a2e; } +.upload-row-info .filename { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; word-break: break-all; } +.sku-input-wrap { display: flex; flex-direction: column; gap: 6px; } +.sku-input-wrap label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.sku-search-row { display: flex; gap: 6px; } +.upload-row-status { display: flex; flex-direction: column; gap: 4px; } +.upload-status-text { font-size: 12px; } +.product-match { font-size: 11px; color: var(--success); } +.product-nomatch { font-size: 11px; color: var(--danger); } +.upload-progress { + height: 4px; border-radius: 4px; background: var(--border2); + overflow: hidden; +} +.upload-progress-bar { height: 100%; background: var(--accent-g); transition: width 0.3s; } + +/* ─── Upload Log ───────────────────────────────────────────────────────── */ +#upload-log { + margin-top: 32px; padding: 20px; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); +} +#upload-log h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-muted); } +#log-entries { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-family: 'Courier New', monospace; max-height: 240px; overflow-y: auto; } +.log-entry { padding: 4px 8px; border-radius: 4px; } +.log-entry.info { color: var(--text-muted); } +.log-entry.success { color: var(--success); background: rgba(0,200,150,0.06); } +.log-entry.error { color: var(--danger); background: rgba(224,92,92,0.06); } + +/* ─── Buttons ──────────────────────────────────────────────────────────── */ +.btn-primary { + padding: 9px 20px; border-radius: var(--radius-sm); + background: var(--accent-g); color: #fff; border: none; + font-size: 13px; font-weight: 600; cursor: pointer; + transition: opacity 0.2s, transform 0.15s, box-shadow 0.2s; + box-shadow: 0 4px 16px rgba(59,125,216,0.3); +} +.btn-primary:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 6px 20px rgba(59,125,216,0.45); } +.btn-primary:active { transform: none; } + +.btn-secondary { + padding: 9px 20px; border-radius: var(--radius-sm); + background: var(--bg-card2); color: var(--text); + border: 1px solid var(--border2); + font-size: 13px; font-weight: 500; cursor: pointer; + transition: all 0.2s; +} +.btn-secondary:hover { background: rgba(255,255,255,0.12); border-color: var(--accent1); } + +.btn-success { + padding: 9px 20px; border-radius: var(--radius-sm); + background: var(--success); color: #fff; border: none; + font-size: 13px; font-weight: 600; cursor: pointer; + transition: all 0.2s; box-shadow: 0 4px 16px rgba(0,200,150,0.3); +} +.btn-success:hover { opacity: 0.9; transform: translateY(-1px); } + +.btn-ghost { + padding: 9px 16px; border-radius: var(--radius-sm); + background: none; color: var(--text-muted); + border: 1px solid transparent; + font-size: 13px; cursor: pointer; transition: all 0.2s; +} +.btn-ghost:hover { color: var(--text); border-color: var(--border2); background: var(--bg-card); } + +.btn-icon { + width: 38px; height: 38px; border-radius: var(--radius-sm); + background: var(--bg-card); border: 1px solid var(--border); + color: var(--text-muted); cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all 0.2s; font-size: 16px; +} +.btn-icon svg { width: 18px; height: 18px; } +.btn-icon:hover { color: var(--text); border-color: var(--border2); background: var(--bg-card2); } + +.btn-sm { padding: 6px 12px !important; font-size: 12px !important; } +.btn-lg { padding: 12px 28px !important; font-size: 15px !important; } + +.btn-danger-outline { + padding: 9px 20px; border-radius: var(--radius-sm); + background: rgba(224,92,92,0.05); color: var(--danger); + border: 1px solid rgba(224,92,92,0.3); + font-size: 13px; font-weight: 500; cursor: pointer; + transition: all 0.2s; +} +.btn-danger-outline:hover { background: rgba(224,92,92,0.12); border-color: var(--danger); } + +.step-actions { + display: flex; gap: 12px; align-items: center; + justify-content: flex-end; margin-top: 8px; +} + +/* ─── Inputs ────────────────────────────────────────────────────────────── */ +input[type="text"], input[type="url"], input[type="password"] { + background: rgba(255,255,255,0.05); + border: 1px solid var(--border2); border-radius: var(--radius-sm); + color: var(--text); font-size: 13px; font-family: inherit; + padding: 9px 12px; width: 100%; outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +input[type="text"]:focus, +input[type="url"]:focus, +input[type="password"]:focus { + border-color: var(--accent1); + box-shadow: 0 0 0 3px rgba(59,125,216,0.15); +} +input::placeholder { color: var(--text-dim); } + +.sku-input { width: 100%; } + +/* ─── Settings Modal ───────────────────────────────────────────────────── */ +.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; } +.modal.hidden { display: none; } +.modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); } +.modal-content { + position: relative; z-index: 1; + width: min(560px, 96vw); + background: #0e1528; border: 1px solid var(--border2); + border-radius: 16px; box-shadow: var(--shadow-lg); + animation: slideUp 0.3s ease; + max-height: 90vh; display: flex; flex-direction: column; +} +@keyframes slideUp { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:none; } } +.modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 20px 24px; border-bottom: 1px solid var(--border); +} +.modal-header h2 { font-size: 18px; font-weight: 700; } +.modal-body { padding: 24px; overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: 28px; } +.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; } + +.settings-section h3 { + font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; + color: var(--accent1); margin-bottom: 16px; +} +.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; } +.form-group label { font-size: 12px; font-weight: 500; color: var(--text-muted); } +.form-group small { font-size: 11px; color: var(--text-dim); } +.input-reveal-row { position: relative; } +.input-reveal-row input { padding-right: 40px; } +.btn-reveal { + position: absolute; right: 10px; bottom: 8px; + background: none; border: none; cursor: pointer; + color: var(--text-muted); font-size: 14px; padding: 0; +} + +.watermark-row { + display: flex; align-items: center; gap: 12px; +} +.watermark-row img { + height: 60px; width: auto; max-width: 120px; + border-radius: var(--radius-sm); border: 1px solid var(--border); + background: white; object-fit: contain; padding: 4px; +} + +#api-status { margin-top: 10px; font-size: 12px; min-height: 18px; } +#api-status.ok { color: var(--success); } +#api-status.err { color: var(--danger); } + +/* ─── Utility ──────────────────────────────────────────────────────────── */ +.hidden { display: none !important; } + +/* ─── Custom Tooltips ─────────────────────────────────────────────────── */ +.custom-tooltip { + position: fixed; + z-index: 9999; + background: rgba(15, 23, 42, 0.9); + backdrop-filter: blur(8px); + border: 1px solid var(--border2); + color: var(--text); + padding: 8px 12px; + border-radius: 8px; + font-size: 11px; + line-height: 1.4; + max-width: 200px; + pointer-events: none; + box-shadow: var(--shadow-md); + opacity: 0; + transform: translateY(4px); + transition: opacity 0.2s, transform 0.2s; +} +.custom-tooltip.visible { + opacity: 1; + transform: none; +} +.custom-tooltip::after { + content: ''; + position: absolute; + border: 6px solid transparent; +} +/* Bottom placement (arrow on top) */ +.custom-tooltip.pos-bottom::after { + bottom: 100%; left: 50%; transform: translateX(-50%); + border-bottom-color: var(--border2); +} +/* Top placement (arrow on bottom) */ +.custom-tooltip.pos-top::after { + top: 100%; left: 50%; transform: translateX(-50%); + border-top-color: var(--border2); +} + +/* ─── Editor Loader ──────────────────────────────────────────────────── */ +.editor-loader { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.4); + backdrop-filter: blur(2px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: white; + gap: 12px; + z-index: 20; + border-radius: var(--radius-md); +} +.spinner { + width: 24px; height: 24px; + border: 3px solid rgba(255,255,255,0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/watermark.png b/watermark.png new file mode 100644 index 0000000..8871614 Binary files /dev/null and b/watermark.png differ