added on WebSTLs (webstls.com) at 2026-07-01 22:26:59
| 1 | <!-- ===================================================================== | |
| 2 | Edit model details. Loaded from /edit_model.cgi?id=N, POSTs back | |
| 3 | to itself, redirects to /models.cgi on save. | |
| 4 | ===================================================================== --> | |
| 5 | ||
| 6 | <style> | |
| 7 | .em-grid { display:grid; grid-template-columns: 1.5fr 1fr; gap:24px; align-items:flex-start; } | |
| 8 | @media (max-width: 1000px) { .em-grid { grid-template-columns: 1fr; } } | |
| 9 | .em-side-hero { aspect-ratio: 4/3; background-size: cover; background-position: center; border-radius: 12px; border: 1px solid var(--col-border); } | |
| 10 | .em-side-note { font-size: 11px; color: var(--col-text-3); margin-top: 8px; line-height: 1.5; } | |
| 11 | .em-side-note a { color: var(--col-accent-bright); text-decoration: none; } | |
| 12 | .em-status-row { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 13 | .em-status-row label { flex: 1; min-width: 130px; padding: 10px 12px; border: 1px solid var(--col-border); border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--col-text-2); display: flex; align-items: center; gap: 8px; transition: all 0.15s; } | |
| 14 | .em-status-row label:has(input:checked) { border-color: var(--col-accent); background: rgba(124,58,237,0.08); color: var(--col-text); } | |
| 15 | ||
| 16 | /* Hero replace/pan affordance -- mirrors the storefront editor. */ | |
| 17 | #em-hero { cursor: grab; position: relative; user-select: none; transition: outline-color 0.15s; outline: 2px solid transparent; outline-offset: 4px; } | |
| 18 | #em-hero:hover { outline-color: rgba(124,58,237,0.65); } | |
| 19 | #em-hero.we-dragging { cursor: grabbing !important; outline-color: #c084fc; } | |
| 20 | #em-hero::after { | |
| 21 | content: "Click to replace . Drag to reposition"; | |
| 22 | position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); | |
| 23 | background: rgba(0,0,0,0.75); color: #fff; | |
| 24 | padding: 6px 14px; border-radius: 6px; | |
| 25 | font-size: 12px; font-family: ui-monospace, monospace; | |
| 26 | letter-spacing: 1px; text-transform: uppercase; | |
| 27 | opacity: 0; transition: opacity 0.15s; | |
| 28 | pointer-events: none; white-space: nowrap; | |
| 29 | } | |
| 30 | #em-hero:hover::after { opacity: 1; } | |
| 31 | #em-hero.we-dragging::after { opacity: 0; } | |
| 32 | #em-hero.is-saving { opacity: 0.6; } | |
| 33 | #em-hero.is-saved { box-shadow: 0 0 0 2px #10b981 inset; } | |
| 34 | </style> | |
| 35 | ||
| 36 | <div class="page-head"> | |
| 37 | <div> | |
| 38 | <span class="page-eyebrow"><a href="/models.cgi" class="text-brand">← Models</a></span> | |
| 39 | <h1 class="page-title">Edit model</h1> | |
| 40 | <p class="page-subtitle">Update the details. Changes take effect across your storefront and queued marketplace pushes.</p> | |
| 41 | </div> | |
| 42 | <div class="page-actions"> | |
| 43 | <a class="btn btn-secondary" href="/models.cgi">Cancel</a> | |
| 44 | <button type="submit" form="emForm" class="btn btn-primary">Save changes</button> | |
| 45 | </div> | |
| 46 | </div> | |
| 47 | ||
| 48 | <form id="emForm" method="post" action="/edit_model.cgi"> | |
| 49 | <input type="hidden" name="id" value="$model_id"> | |
| 50 | ||
| 51 | <div class="em-grid"> | |
| 52 | ||
| 53 | <!-- ============ LEFT: editable fields ============ --> | |
| 54 | <div style="display:flex;flex-direction:column;gap:16px"> | |
| 55 | ||
| 56 | <div class="module"> | |
| 57 | <div class="module-head"><div class="left"> | |
| 58 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></div> | |
| 59 | <div><div class="module-title">Listing details</div><div class="module-sub">What buyers see when they land on this model.</div></div> | |
| 60 | </div></div> | |
| 61 | <div class="module-body"> | |
| 62 | <div class="form-group"> | |
| 63 | <label class="form-label">Title <span class="req">*</span></label> | |
| 64 | <input class="input" name="title" value="$title" maxlength="200" required> | |
| 65 | </div> | |
| 66 | <div class="form-group"> | |
| 67 | <label class="form-label">Short tagline</label> | |
| 68 | <input class="input" name="tagline" value="$tagline" maxlength="180" placeholder="One-line summary shown on cards"> | |
| 69 | </div> | |
| 70 | <div class="form-group"> | |
| 71 | <label class="form-label">Full description</label> | |
| 72 | <textarea class="textarea" name="description" rows="8" placeholder="Detailed description, materials, scale, what's included...">$description</textarea> | |
| 73 | </div> | |
| 74 | <div class="dash-grid-2" style="gap:14px"> | |
| 75 | <div class="form-group"> | |
| 76 | <label class="form-label">Category</label> | |
| 77 | <select class="select" name="category"> | |
| 78 | <option value="">— Select —</option> | |
| 79 | [loop:@category_options] | |
| 80 | <option value="$loop1.value" $loop1.selected>$loop1.label</option> | |
| 81 | [/loop] | |
| 82 | </select> | |
| 83 | </div> | |
| 84 | <div class="form-group"> | |
| 85 | <label class="form-label">License</label> | |
| 86 | <select class="select" name="license_type"> | |
| 87 | <option value="personal">Personal use only</option> | |
| 88 | <option value="personal_attrib">Personal + attribution required</option> | |
| 89 | <option value="commercial">Commercial use allowed</option> | |
| 90 | <option value="cc_by">Creative Commons CC-BY</option> | |
| 91 | <option value="cc_by_nc">Creative Commons CC-BY-NC</option> | |
| 92 | <option value="cc_by_sa">Creative Commons CC-BY-SA</option> | |
| 93 | <option value="cc0">CC0 (Public Domain)</option> | |
| 94 | </select> | |
| 95 | </div> | |
| 96 | </div> | |
| 97 | </div> | |
| 98 | </div> | |
| 99 | ||
| 100 | <div class="module"> | |
| 101 | <div class="module-head"><div class="left"> | |
| 102 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg></div> | |
| 103 | <div><div class="module-title">Price</div><div class="module-sub">Base price in USD. WebSTLs takes 15% on paid sales.</div></div> | |
| 104 | </div></div> | |
| 105 | <div class="module-body"> | |
| 106 | <div class="form-group"> | |
| 107 | <label class="form-label">Price (\$)</label> | |
| 108 | <input type="text" class="input" name="price" value="$price" placeholder="0.00"> | |
| 109 | </div> | |
| 110 | </div> | |
| 111 | </div> | |
| 112 | ||
| 113 | </div> | |
| 114 | ||
| 115 | <!-- ============ RIGHT: status, visibility, hero preview ============ --> | |
| 116 | <div style="display:flex;flex-direction:column;gap:16px"> | |
| 117 | ||
| 118 | <div class="module"> | |
| 119 | <div class="module-head"><div class="left"> | |
| 120 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg></div> | |
| 121 | <div><div class="module-title">Status</div><div class="module-sub">Draft = nobody can see it. Published = live everywhere.</div></div> | |
| 122 | </div></div> | |
| 123 | <div class="module-body"> | |
| 124 | <select class="select" name="status"> | |
| 125 | <option value="draft" $sel_status_draft>Draft — only you can see it</option> | |
| 126 | <option value="published" $sel_status_published>Published — live on your storefront</option> | |
| 127 | <option value="unlisted" $sel_status_unlisted>Unlisted — reachable by direct link only</option> | |
| 128 | <option value="archived" $sel_status_archived>Archived — hidden, kept in your account</option> | |
| 129 | </select> | |
| 130 | </div> | |
| 131 | </div> | |
| 132 | ||
| 133 | <div class="module"> | |
| 134 | <div class="module-head"><div class="left"> | |
| 135 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></div> | |
| 136 | <div><div class="module-title">Visibility</div><div class="module-sub">Discoverable in catalogs and search?</div></div> | |
| 137 | </div></div> | |
| 138 | <div class="module-body"> | |
| 139 | <div class="em-status-row"> | |
| 140 | <label><input type="radio" name="visibility" value="public" $sel_vis_public> Public</label> | |
| 141 | <label><input type="radio" name="visibility" value="unlisted" $sel_vis_unlisted> Unlisted</label> | |
| 142 | <label><input type="radio" name="visibility" value="private" $sel_vis_private> Private</label> | |
| 143 | </div> | |
| 144 | </div> | |
| 145 | </div> | |
| 146 | ||
| 147 | <div class="module"> | |
| 148 | <div class="module-head"><div class="left"> | |
| 149 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg></div> | |
| 150 | <div><div class="module-title">Hero image</div><div class="module-sub">Click to replace. Drag to set the focal point.</div></div> | |
| 151 | </div></div> | |
| 152 | <div class="module-body"> | |
| 153 | <div class="em-side-hero" id="em-hero" | |
| 154 | data-we-editable="image" | |
| 155 | data-we-field="hero_image_url" | |
| 156 | data-we-target="model" | |
| 157 | data-we-target-id="$model_id" | |
| 158 | data-we-pos-field="hero_image_pos" | |
| 159 | data-we-pos="$hero_image_pos" | |
| 160 | style="background-image:url('$hero_image');background-position:$hero_image_pos"></div> | |
| 161 | <p class="em-side-note"> | |
| 162 | The image saves to this model immediately on upload/drag — you do not need to hit Save changes for it. | |
| 163 | </p> | |
| 164 | </div> | |
| 165 | </div> | |
| 166 | ||
| 167 | <div class="module"> | |
| 168 | <div class="module-body" style="display:flex;flex-direction:column;gap:8px"> | |
| 169 | <button type="submit" class="btn btn-primary" style="width:100%">Save changes</button> | |
| 170 | <a href="/models.cgi" class="btn btn-secondary" style="width:100%;text-align:center">Cancel</a> | |
| 171 | </div> | |
| 172 | </div> | |
| 173 | ||
| 174 | </div> | |
| 175 | </div> | |
| 176 | ||
| 177 | </form> | |
| 178 | ||
| 179 | ||
| 180 | <script> | |
| 181 | /* ===================================================================== | |
| 182 | Hero replace + focal-point picker for /edit_model.cgi. | |
| 183 | Click (no drag) -> file picker, upload via /upload_image.cgi, save | |
| 184 | the resulting /img.cgi?id=... URL to models.hero_image_url via | |
| 185 | /update_model_field.cgi. Drag -> pan, save background-position to | |
| 186 | models.hero_image_pos. Mirrors the storefront editor behavior so | |
| 187 | the experience is identical in both places. | |
| 188 | ===================================================================== */ | |
| 189 | (function() { | |
| 190 | var hero = document.getElementById('em-hero'); | |
| 191 | if (!hero) return; | |
| 192 | var mid = hero.getAttribute('data-we-target-id'); | |
| 193 | if (!mid) return; | |
| 194 | ||
| 195 | function postText(url, fd) { | |
| 196 | return fetch(url, { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 197 | .then(function(r){ | |
| 198 | return r.text().then(function(body){ | |
| 199 | try { return JSON.parse(body); } | |
| 200 | catch (e) { | |
| 201 | var snippet = body ? body.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim().slice(0, 200) : '(empty body)'; | |
| 202 | throw new Error('server returned ' + r.status + ' ' + r.statusText + ' (' + snippet + ')'); | |
| 203 | } | |
| 204 | }); | |
| 205 | }); | |
| 206 | } | |
| 207 | ||
| 208 | function saveModelField(field, value) { | |
| 209 | var fd = new FormData(); | |
| 210 | fd.append('field', field); | |
| 211 | fd.append('value', value); | |
| 212 | fd.append('model_id', mid); | |
| 213 | return postText('/update_model_field.cgi', fd); | |
| 214 | } | |
| 215 | ||
| 216 | function flash(el, klass) { | |
| 217 | el.classList.add(klass); | |
| 218 | setTimeout(function(){ el.classList.remove(klass); }, 1200); | |
| 219 | } | |
| 220 | ||
| 221 | function readBgPos(el) { | |
| 222 | var raw = el.dataset.wePos || el.style.backgroundPosition || '50% 50%'; | |
| 223 | var parts = raw.split(/\s+/); | |
| 224 | var x = parseFloat(parts[0]); if (isNaN(x)) x = 50; | |
| 225 | var y = parseFloat(parts[1]); if (isNaN(y)) y = 50; | |
| 226 | return [x, y]; | |
| 227 | } | |
| 228 | ||
| 229 | /* ---- Drag-to-pan (Pointer Events: mouse + touch + pen) ---- | |
| 230 | Was mouse-only and broken on phones. Now: pointerdown begins | |
| 231 | tracking, pointermove past the threshold is treated as a drag | |
| 232 | (saves new position on release); below the threshold a release | |
| 233 | is a tap -> open file picker. touch-action:none + the WebKit | |
| 234 | suppressors stop iOS from popping its own long-press menu over | |
| 235 | the hero or treating the gesture as a page scroll. */ | |
| 236 | hero.style.touchAction = 'none'; | |
| 237 | hero.style.webkitUserSelect = 'none'; | |
| 238 | hero.style.userSelect = 'none'; | |
| 239 | hero.style.webkitTouchCallout = 'none'; | |
| 240 | hero.style.webkitUserDrag = 'none'; | |
| 241 | var drag = null; | |
| 242 | function dragStart(e) { | |
| 243 | if (e.button !== undefined && e.button !== 0) return; | |
| 244 | if (e.target.closest('a,button,input,select,textarea')) return; | |
| 245 | e.preventDefault(); | |
| 246 | var bg = readBgPos(hero); | |
| 247 | drag = { | |
| 248 | pointerId: e.pointerId, | |
| 249 | pointerType: e.pointerType || 'mouse', | |
| 250 | startX: e.clientX, startY: e.clientY, | |
| 251 | rect: hero.getBoundingClientRect(), | |
| 252 | startBgX: bg[0], startBgY: bg[1], | |
| 253 | moved: false, pos: null | |
| 254 | }; | |
| 255 | try { hero.setPointerCapture(e.pointerId); } catch(_) {} | |
| 256 | } | |
| 257 | function dragMove(e) { | |
| 258 | if (!drag || e.pointerId !== drag.pointerId) return; | |
| 259 | var dx = e.clientX - drag.startX; | |
| 260 | var dy = e.clientY - drag.startY; | |
| 261 | /* Higher threshold on touch since finger jitter is naturally larger. */ | |
| 262 | var thresh = drag.pointerType === 'mouse' ? 5 : 12; | |
| 263 | if (!drag.moved && Math.abs(dx) < thresh && Math.abs(dy) < thresh) return; | |
| 264 | drag.moved = true; | |
| 265 | hero.classList.add('we-dragging'); | |
| 266 | var r = drag.rect; | |
| 267 | var px = drag.startBgX - (dx / r.width * 100); | |
| 268 | var py = drag.startBgY - (dy / r.height * 100); | |
| 269 | if (px < 0) px = 0; if (px > 100) px = 100; | |
| 270 | if (py < 0) py = 0; if (py > 100) py = 100; | |
| 271 | var pos = px.toFixed(1) + '% ' + py.toFixed(1) + '%'; | |
| 272 | drag.pos = pos; | |
| 273 | hero.style.backgroundPosition = pos; | |
| 274 | hero.dataset.wePos = pos; | |
| 275 | } | |
| 276 | function dragEnd(e) { | |
| 277 | if (!drag || e.pointerId !== drag.pointerId) return; | |
| 278 | var st = drag; drag = null; | |
| 279 | hero.classList.remove('we-dragging'); | |
| 280 | if (st.moved && st.pos) { | |
| 281 | hero.classList.add('is-saving'); | |
| 282 | saveModelField('hero_image_pos', st.pos) | |
| 283 | .then(function(data){ | |
| 284 | hero.classList.remove('is-saving'); | |
| 285 | if (data && data.success) flash(hero, 'is-saved'); | |
| 286 | else appToast({ message: 'Reposition failed: ' + (data && data.error || 'unknown'), type: 'error' }); | |
| 287 | }) | |
| 288 | .catch(function(err){ | |
| 289 | hero.classList.remove('is-saving'); | |
| 290 | appToast({ message: 'Reposition failed: ' + err.message, type: 'error' }); | |
| 291 | }); | |
| 292 | return; | |
| 293 | } | |
| 294 | /* Pure click/tap -> open file picker */ | |
| 295 | openReplace(); | |
| 296 | } | |
| 297 | hero.addEventListener('pointerdown', dragStart); | |
| 298 | hero.addEventListener('pointermove', dragMove); | |
| 299 | hero.addEventListener('pointerup', dragEnd); | |
| 300 | hero.addEventListener('pointercancel', dragEnd); | |
| 301 | hero.addEventListener('dragstart', function(e){ e.preventDefault(); }); | |
| 302 | hero.addEventListener('contextmenu', function(e){ e.preventDefault(); }); | |
| 303 | ||
| 304 | function openReplace() { | |
| 305 | var input = document.createElement('input'); | |
| 306 | input.type = 'file'; | |
| 307 | input.accept = 'image/*'; | |
| 308 | input.onchange = function() { | |
| 309 | if (!input.files || !input.files[0]) return; | |
| 310 | var file = input.files[0]; | |
| 311 | var MAX = 10 * 1024 * 1024; | |
| 312 | if (file.size > MAX) { | |
| 313 | appToast({ message: 'Image is too large (' + (file.size / 1048576).toFixed(1) + ' MB). Max 10 MB.', type: 'warning' }); | |
| 314 | return; | |
| 315 | } | |
| 316 | hero.classList.add('is-saving'); | |
| 317 | var fd = new FormData(); | |
| 318 | fd.append('image', file); | |
| 319 | postText('/upload_image.cgi', fd) | |
| 320 | .then(function(data){ | |
| 321 | if (!data || !data.success || !data.url) throw new Error(data && data.error || 'upload failed'); | |
| 322 | return saveModelField('hero_image_url', data.url).then(function(saved){ | |
| 323 | if (!saved || !saved.success) throw new Error(saved && saved.error || 'save failed'); | |
| 324 | hero.style.backgroundImage = "url('" + data.url + "')"; | |
| 325 | hero.classList.remove('is-saving'); | |
| 326 | flash(hero, 'is-saved'); | |
| 327 | }); | |
| 328 | }) | |
| 329 | .catch(function(err){ | |
| 330 | hero.classList.remove('is-saving'); | |
| 331 | appToast({ message: 'Image change failed: ' + err.message, type: 'error' }); | |
| 332 | }); | |
| 333 | }; | |
| 334 | input.click(); | |
| 335 | } | |
| 336 | })(); | |
| 337 | </script> |