added on local at 2026-07-01 13:48:12
| 1 | <!-- ===================================================================== | |
| 2 | Upload & Publish — full creator workflow on one page. | |
| 3 | Drop files, fill listing details, pick marketplaces, schedule the | |
| 4 | release. Submits to /upload_model.cgi (POST), which inserts the model, | |
| 5 | queues marketplace_pushes rows, and redirects to /models.cgi. | |
| 6 | ===================================================================== --> | |
| 7 | ||
| 8 | <style> | |
| 9 | .up-dropzone { border:2px dashed var(--col-border-2); border-radius:14px; padding:34px 20px; text-align:center; background:var(--col-surface-2); cursor:pointer; transition:all .15s; } | |
| 10 | .up-dropzone:hover, .up-dropzone.drag-over { border-color:var(--col-accent); background:var(--grad-brand-soft); } | |
| 11 | .up-dropzone-icon { width:42px; height:42px; margin:0 auto 10px; color:var(--col-accent-bright); } | |
| 12 | .up-dropzone-icon svg { width:100%; height:100%; } | |
| 13 | .up-dropzone-title { font-weight:600; color:var(--col-text); margin-bottom:4px; font-size:15px; } | |
| 14 | .up-dropzone-sub { color:var(--col-text-3); font-size:13px; } | |
| 15 | .up-dropzone-sub .browse { color:var(--col-accent-bright); cursor:pointer; font-weight:600; } | |
| 16 | .up-dropzone-types { margin-top:10px; font-family:var(--font-mono,monospace); font-size:10px; letter-spacing:1.5px; color:var(--col-text-3); text-transform:uppercase; } | |
| 17 | ||
| 18 | .up-file-list { display:flex; flex-direction:column; gap:8px; margin-top:12px; } | |
| 19 | .up-file-item { display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--col-border); border-radius:10px; background:var(--col-surface-2); } | |
| 20 | .up-file-ext { width:36px; height:36px; border-radius:8px; background:var(--grad-brand-soft); display:grid; place-items:center; color:var(--col-accent-bright); font-family:var(--font-mono,monospace); font-size:11px; font-weight:700; flex-shrink:0; } | |
| 21 | .up-file-info { flex:1; min-width:0; } | |
| 22 | .up-file-name { font-size:13px; font-weight:600; color:var(--col-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| 23 | .up-file-meta { font-size:11px; color:var(--col-text-3); } | |
| 24 | .up-file-remove { background:none; border:0; color:var(--col-text-3); cursor:pointer; font-size:18px; padding:4px 8px; } | |
| 25 | .up-file-remove:hover { color:#ef4444; } | |
| 26 | ||
| 27 | .up-img-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(140px, 1fr)); gap:10px; margin-top:12px; } | |
| 28 | .up-img-thumb { position:relative; aspect-ratio:4/3; border-radius:10px; overflow:hidden; border:1px solid var(--col-border); background:var(--col-surface-2); } | |
| 29 | .up-img-thumb.is-cover { border:2px solid var(--col-accent); } | |
| 30 | .up-img-thumb img { width:100%; height:100%; object-fit:cover; display:block; } | |
| 31 | .up-img-badge { position:absolute; top:6px; left:6px; background:var(--col-accent); color:#fff; font-size:9px; padding:2px 8px; border-radius:4px; letter-spacing:1px; font-weight:700; } | |
| 32 | .up-img-x { position:absolute; top:4px; right:4px; width:22px; height:22px; border:0; border-radius:50%; background:rgba(0,0,0,0.65); color:#fff; cursor:pointer; display:grid; place-items:center; font-size:13px; line-height:1; } | |
| 33 | .up-img-empty { grid-column:1/-1; text-align:center; color:var(--col-text-3); font-size:12px; padding:16px; border:1px dashed var(--col-border); border-radius:10px; } | |
| 34 | ||
| 35 | .up-row-list { display:flex; flex-direction:column; gap:8px; } | |
| 36 | .up-row { display:flex; align-items:center; gap:10px; } | |
| 37 | .up-row .icon { width:28px; height:28px; flex-shrink:0; display:grid; place-items:center; background:var(--col-surface-2); border:1px solid var(--col-border); border-radius:6px; color:var(--col-accent-bright); font-size:13px; } | |
| 38 | .up-row input.input { flex:1; } | |
| 39 | .up-row-x { background:none; border:0; color:var(--col-text-3); cursor:pointer; font-size:16px; padding:4px 8px; } | |
| 40 | .up-row-x:hover:not(:disabled) { color:#ef4444; } | |
| 41 | .up-row-x:disabled { opacity:0.3; cursor:not-allowed; } | |
| 42 | ||
| 43 | .up-add-btn { margin-top:8px; padding:8px 14px; background:none; border:1px dashed var(--col-border-2); color:var(--col-text-2); border-radius:8px; cursor:pointer; font-size:12px; letter-spacing:1px; text-transform:uppercase; font-weight:600; font-family:inherit; transition:all .15s; } | |
| 44 | .up-add-btn:hover { border-color:var(--col-accent); color:var(--col-accent-bright); } | |
| 45 | ||
| 46 | .up-check-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(170px, 1fr)); gap:8px 14px; } | |
| 47 | .up-check { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--col-text-2); cursor:pointer; } | |
| 48 | .up-check input { accent-color:var(--col-accent); } | |
| 49 | ||
| 50 | .up-price-toggle { display:flex; gap:0; margin-bottom:10px; border:1px solid var(--col-border); border-radius:8px; overflow:hidden; } | |
| 51 | .up-price-toggle button { flex:1; padding:10px; background:var(--col-surface-2); border:0; color:var(--col-text-3); cursor:pointer; font-family:inherit; font-size:12px; letter-spacing:1.5px; font-weight:700; text-transform:uppercase; transition:all .15s; } | |
| 52 | .up-price-toggle button.is-active { background:var(--col-accent); color:#fff; } | |
| 53 | ||
| 54 | .up-price-input { position:relative; } | |
| 55 | ||
| 56 | /* Upload progress overlay -- shown while files transfer + the server | |
| 57 | writes the model row. Fixed full-viewport scrim + centered card. */ | |
| 58 | #upProgressOverlay { | |
| 59 | position: fixed; inset: 0; z-index: 9000; | |
| 60 | background: rgba(8, 11, 24, 0.78); | |
| 61 | backdrop-filter: blur(6px); | |
| 62 | display: grid; place-items: center; | |
| 63 | animation: upProgFadeIn 0.18s ease-out; | |
| 64 | } | |
| 65 | @keyframes upProgFadeIn { from { opacity:0; } to { opacity:1; } } | |
| 66 | .up-prog-card { | |
| 67 | background: var(--col-surface-1); | |
| 68 | border: 1px solid var(--col-border); | |
| 69 | border-radius: 18px; | |
| 70 | padding: 32px 36px; | |
| 71 | width: min(440px, 92vw); | |
| 72 | text-align: center; | |
| 73 | box-shadow: 0 24px 60px rgba(0,0,0,0.55); | |
| 74 | } | |
| 75 | .up-prog-icon { | |
| 76 | width: 60px; height: 60px; | |
| 77 | margin: 0 auto 16px; | |
| 78 | border-radius: 16px; | |
| 79 | display: grid; place-items: center; | |
| 80 | background: linear-gradient(135deg, #3b82f6, #6366f1); | |
| 81 | color: #fff; | |
| 82 | box-shadow: 0 0 24px rgba(59,130,246,0.55); | |
| 83 | } | |
| 84 | .up-prog-title { | |
| 85 | font-family: var(--font-display, inherit); | |
| 86 | font-size: 20px; font-weight: 800; color: #fff; | |
| 87 | margin-bottom: 6px; | |
| 88 | } | |
| 89 | .up-prog-sub { | |
| 90 | font-size: 13px; color: var(--col-text-2); | |
| 91 | line-height: 1.55; margin-bottom: 20px; | |
| 92 | } | |
| 93 | .up-prog-bar { | |
| 94 | height: 8px; | |
| 95 | background: var(--col-surface-3); | |
| 96 | border-radius: 999px; | |
| 97 | overflow: hidden; | |
| 98 | position: relative; | |
| 99 | } | |
| 100 | .up-prog-bar-fill { | |
| 101 | height: 100%; | |
| 102 | width: 0%; | |
| 103 | background: linear-gradient(90deg, #3b82f6, #6366f1, #a855f7); | |
| 104 | background-size: 200% 100%; | |
| 105 | border-radius: 999px; | |
| 106 | transition: width 0.2s ease-out; | |
| 107 | } | |
| 108 | /* Indeterminate state -- pulse the bar across when the server is | |
| 109 | processing post-upload (no length info available). */ | |
| 110 | .up-prog-bar-fill.is-indeterminate { | |
| 111 | width: 100% !important; | |
| 112 | animation: upProgPulse 1.4s ease-in-out infinite; | |
| 113 | } | |
| 114 | @keyframes upProgPulse { | |
| 115 | 0%, 100% { background-position: 0% 50%; opacity: 0.55; } | |
| 116 | 50% { background-position: 100% 50%; opacity: 1; } | |
| 117 | } | |
| 118 | .up-prog-meta { | |
| 119 | margin-top: 12px; | |
| 120 | font-size: 12px; | |
| 121 | color: var(--col-text-3); | |
| 122 | font-family: var(--font-mono, monospace); | |
| 123 | letter-spacing: 0.3px; | |
| 124 | } | |
| 125 | .up-price-input .currency { position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--col-text-3); font-weight:600; } | |
| 126 | .up-price-input input { padding-left:30px; } | |
| 127 | ||
| 128 | .up-mp-row { display:flex; align-items:center; gap:12px; padding:10px 12px; border:1px solid var(--col-border); border-radius:10px; background:var(--col-surface-2); cursor:pointer; transition:all .15s; } | |
| 129 | .up-mp-row:hover:not(.is-locked) { border-color:var(--col-accent); } | |
| 130 | .up-mp-row.is-checked { border-color:var(--col-accent); background:var(--grad-brand-soft); } | |
| 131 | .up-mp-row.is-locked { opacity:0.5; cursor:not-allowed; } | |
| 132 | .up-mp-icon { width:30px; height:30px; border-radius:7px; background:linear-gradient(130deg, var(--col-accent), var(--col-accent-bright)); display:grid; place-items:center; color:#fff; font-size:13px; font-weight:700; flex-shrink:0; } | |
| 133 | .up-mp-name { flex:1; font-size:13px; font-weight:600; color:var(--col-text); } | |
| 134 | .up-mp-status { font-size:10px; color:var(--col-text-3); letter-spacing:1px; text-transform:uppercase; } | |
| 135 | .up-mp-row input[type=checkbox] { accent-color:var(--col-accent); } | |
| 136 | ||
| 137 | .up-demo-counter { display:flex; align-items:center; justify-content:space-between; margin-top:10px; padding:10px 12px; background:rgba(124,58,237,0.08); border:1px dashed rgba(124,58,237,0.3); border-radius:8px; font-size:12px; color:var(--col-text-2); } | |
| 138 | .up-demo-counter strong { color:var(--col-accent-bright); } | |
| 139 | .up-demo-counter .up-upgrade { font-size:11px; color:var(--col-accent-bright); text-decoration:none; font-weight:600; letter-spacing:1px; text-transform:uppercase; } | |
| 140 | ||
| 141 | .up-section-num { width:32px; height:32px; border-radius:8px; background:var(--grad-brand-soft); display:grid; place-items:center; color:var(--col-accent-bright); font-family:var(--font-mono,monospace); font-size:12px; font-weight:700; flex-shrink:0; } | |
| 142 | ||
| 143 | .up-sched-options { display:flex; flex-direction:column; gap:6px; } | |
| 144 | .up-sched-options label { display:flex; align-items:center; gap:10px; padding:10px 12px; border:1px solid var(--col-border); border-radius:8px; cursor:pointer; font-size:13px; color:var(--col-text-2); } | |
| 145 | .up-sched-options label.is-selected { border-color:var(--col-accent); background:var(--grad-brand-soft); color:var(--col-text); } | |
| 146 | .up-sched-when { display:none; margin-top:10px; gap:8px; } | |
| 147 | .up-sched-when.is-shown { display:flex; } | |
| 148 | .up-sched-when input { flex:1; } | |
| 149 | ||
| 150 | /* Product-type tabs (Digital / Physical / Both) -- chooses which | |
| 151 | listing fields the seller fills in. Sits above the step strip. | |
| 152 | Each kind owns a color (cyan/emerald/violet) that runs through | |
| 153 | the icon, the left edge accent, and the active fill so the three | |
| 154 | options are visually distinct at a glance. */ | |
| 155 | .up-kind-tabs { display:flex; gap:8px; padding:6px; background:var(--col-surface-1); border:1px solid var(--col-border); border-radius:12px; margin-bottom:14px; } | |
| 156 | .up-kind-tab { flex:1; position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:6px; padding:14px 16px 14px 14px; background:transparent; border:1px solid transparent; border-left:3px solid var(--kind-color, transparent); border-radius:9px; color:var(--col-text-2); cursor:pointer; font-family:inherit; transition:all .18s; text-align:left; } | |
| 157 | ||
| 158 | /* Per-kind accent tokens. --kind-soft is the resting/active fill, | |
| 159 | --kind-glow is the outer shadow used on the active state. */ | |
| 160 | .up-kind-tab[data-kind="digital"] { --kind-color:#10b981; --kind-soft:rgba(16,185,129,0.10); --kind-glow:rgba(16,185,129,0.28); } | |
| 161 | .up-kind-tab[data-kind="physical"] { --kind-color:#f59e0b; --kind-soft:rgba(245,158,11,0.10); --kind-glow:rgba(245,158,11,0.30); } | |
| 162 | .up-kind-tab[data-kind="both"] { --kind-color:#7c3aed; --kind-soft:rgba(124,58,237,0.12); --kind-glow:rgba(124,58,237,0.30); } | |
| 163 | ||
| 164 | /* Icon (the first SVG inside the title) picks up the kind color. */ | |
| 165 | .up-kind-tab .up-kind-title > svg { color:var(--kind-color); transition:color .18s, transform .18s; } | |
| 166 | .up-kind-tab:hover .up-kind-title > svg { transform:scale(1.08); } | |
| 167 | ||
| 168 | .up-kind-tab:hover:not(.is-active) { background:var(--kind-soft); color:var(--col-text); border-color:color-mix(in srgb, var(--kind-color) 35%, transparent); } | |
| 169 | .up-kind-tab.is-active { background:var(--kind-soft); border-color:var(--kind-color); color:var(--col-text); box-shadow:0 0 0 1px var(--kind-color) inset, 0 8px 24px -10px var(--kind-glow); } | |
| 170 | .up-kind-tab.is-active .up-kind-title { color:var(--kind-color); } | |
| 171 | ||
| 172 | .up-kind-tab .up-kind-title { font-size:14px; font-weight:700; letter-spacing:0.2px; display:flex; align-items:center; gap:8px; } | |
| 173 | .up-kind-tab .up-kind-sub { font-size:11px; color:var(--col-text-3); line-height:1.45; } | |
| 174 | .up-kind-tab.is-active .up-kind-sub { color:var(--col-text-2); } | |
| 175 | .up-kind-tab .up-kind-emoji { width:18px; height:18px; display:inline-grid; place-items:center; } | |
| 176 | ||
| 177 | /* ---- Form-wide kind tinting ---- | |
| 178 | When a kind tab is active, the step strip's active pill and the | |
| 179 | section number badges pick up that kind's color. Combined with a | |
| 180 | short pulse on the badges (triggered from JS on click), this | |
| 181 | gives the seller a clear "the form responded" signal. */ | |
| 182 | body.kind-digital { --current-kind:#10b981; --current-kind-soft:rgba(16,185,129,0.12); --current-kind-glow:rgba(16,185,129,0.32); } | |
| 183 | body.kind-physical { --current-kind:#f59e0b; --current-kind-soft:rgba(245,158,11,0.12); --current-kind-glow:rgba(245,158,11,0.32); } | |
| 184 | body.kind-both { --current-kind:#7c3aed; --current-kind-soft:rgba(124,58,237,0.14); --current-kind-glow:rgba(124,58,237,0.32); } | |
| 185 | ||
| 186 | body[class*="kind-"] .up-section-num { background:var(--current-kind-soft); color:var(--current-kind); transition:background .3s, color .3s, box-shadow .3s, transform .3s; } | |
| 187 | body[class*="kind-"] .up-section-num.is-pulsing { animation:kindPulse .55s ease-out; } | |
| 188 | /* Subtle wash on the module cards themselves so the whole form | |
| 189 | reads as belonging to the selected kind. 5% on the fill keeps | |
| 190 | it gentle on large surfaces; the border carries more of the | |
| 191 | color since it's only 1px wide. */ | |
| 192 | body[class*="kind-"] .module { | |
| 193 | background: color-mix(in srgb, var(--current-kind) 5%, var(--col-surface-1)); | |
| 194 | border-color: color-mix(in srgb, var(--current-kind) 22%, var(--col-border)); | |
| 195 | transition: background .35s, border-color .35s; | |
| 196 | } | |
| 197 | ||
| 198 | \@keyframes kindPulse { | |
| 199 | 0% { transform:scale(1); box-shadow:0 0 0 0 var(--current-kind-glow); } | |
| 200 | 50% { transform:scale(1.12); box-shadow:0 0 0 8px transparent; } | |
| 201 | 100% { transform:scale(1); box-shadow:0 0 0 0 transparent; } | |
| 202 | } | |
| 203 | ||
| 204 | /* Form-kind banner: a single slim header above the form that names | |
| 205 | what the seller is creating and changes wording + tint per kind. | |
| 206 | The three title/sub spans live in the DOM together; CSS shows | |
| 207 | only the one matching the active body kind class. */ | |
| 208 | .up-form-header { display:flex; align-items:center; gap:14px; padding:14px 18px; margin-bottom:16px; background:var(--col-surface-1); border:1px solid var(--col-border); border-radius:12px; transition:background .35s, border-color .35s; } | |
| 209 | body[class*="kind-"] .up-form-header { background:color-mix(in srgb, var(--current-kind) 8%, var(--col-surface-1)); border-color:color-mix(in srgb, var(--current-kind) 30%, var(--col-border)); } | |
| 210 | ||
| 211 | .up-form-header-icon { width:34px; height:34px; flex-shrink:0; display:grid; place-items:center; border-radius:9px; background:var(--col-surface-2); color:var(--col-text-2); transition:background .3s, color .3s; } | |
| 212 | body[class*="kind-"] .up-form-header-icon { color:var(--current-kind); background:color-mix(in srgb, var(--current-kind) 16%, transparent); } | |
| 213 | .up-form-header-icon svg { width:18px; height:18px; } | |
| 214 | ||
| 215 | .up-form-header-title { font-size:14px; font-weight:700; letter-spacing:0.2px; color:var(--col-text); transition:color .3s; } | |
| 216 | body[class*="kind-"] .up-form-header-title strong { color:var(--current-kind); } | |
| 217 | .up-form-header-title strong { font-weight:700; } | |
| 218 | .up-form-header-sub { font-size:12px; color:var(--col-text-3); line-height:1.45; margin-top:2px; } | |
| 219 | ||
| 220 | /* Show only the SVG / text span matching the current body kind. */ | |
| 221 | .ufh-d, .ufh-p, .ufh-b { display:none; } | |
| 222 | body.kind-digital .ufh-d { display:inline; } | |
| 223 | body.kind-physical .ufh-p { display:inline; } | |
| 224 | body.kind-both .ufh-b { display:inline; } | |
| 225 | body.kind-digital svg.ufh-d, body.kind-physical svg.ufh-p, body.kind-both svg.ufh-b { display:block; } | |
| 226 | ||
| 227 | \@media (max-width: 900px) { | |
| 228 | .up-grid { grid-template-columns:1fr !important; } | |
| 229 | .up-kind-tabs { flex-direction:column; } | |
| 230 | } | |
| 231 | </style> | |
| 232 | ||
| 233 | <div class="page-head"> | |
| 234 | <div> | |
| 235 | <span class="page-eyebrow"> | |
| 236 | [if:$is_edit]<a href="/models.cgi" class="text-brand">← Models</a> · editing model #$edit_id[/if] | |
| 237 | [if:!$is_edit]<span class="dot"></span> New Model[/if] | |
| 238 | </span> | |
| 239 | <h1 class="page-title">$page_title</h1> | |
| 240 | <p class="page-subtitle">$page_sub</p> | |
| 241 | </div> | |
| 242 | <div class="page-actions"> | |
| 243 | [if:!$is_edit] | |
| 244 | <button type="button" class="btn btn-secondary" id="upSaveDraftTop">Save draft</button> | |
| 245 | [/if] | |
| 246 | <button type="submit" form="upForm" name="_action" value="publish" class="btn btn-primary"> | |
| 247 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7Z"/></svg> | |
| 248 | $submit_label | |
| 249 | </button> | |
| 250 | </div> | |
| 251 | </div> | |
| 252 | ||
| 253 | [if:$just_saved] | |
| 254 | <div class="banner success" style="margin-bottom:18px"> | |
| 255 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> | |
| 256 | <div class="text-xs">Changes saved.</div> | |
| 257 | </div> | |
| 258 | [/if] | |
| 259 | ||
| 260 | <!-- Product-type tabs. Picks whether this listing is a digital file | |
| 261 | (default), a 3D-printed physical item the seller ships, or both. | |
| 262 | The hidden product_kind input lives inside the form below; JS | |
| 263 | wires the tab clicks to swap its value and reveal/hide the | |
| 264 | Physical Details module + adjust the file dropzone copy. --> | |
| 265 | <div class="up-kind-tabs" id="upKindTabs" role="tablist" aria-label="Product type"> | |
| 266 | <button type="button" class="up-kind-tab $kind_digital_active" data-kind="digital" role="tab"> | |
| 267 | <span class="up-kind-title"> | |
| 268 | <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> | |
| 269 | Digital file | |
| 270 | </span> | |
| 271 | <span class="up-kind-sub">Sell a downloadable STL / OBJ / 3MF. Buyers get instant download links after checkout.</span> | |
| 272 | </button> | |
| 273 | <button type="button" class="up-kind-tab $kind_physical_active" data-kind="physical" role="tab"> | |
| 274 | <span class="up-kind-title"> | |
| 275 | <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> | |
| 276 | 3D-printed item | |
| 277 | </span> | |
| 278 | <span class="up-kind-sub">You print and ship it. Set the price, stock, production time, and shipping rates.</span> | |
| 279 | </button> | |
| 280 | <button type="button" class="up-kind-tab $kind_both_active" data-kind="both" role="tab"> | |
| 281 | <span class="up-kind-title"> | |
| 282 | <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg> | |
| 283 | Both | |
| 284 | </span> | |
| 285 | <span class="up-kind-sub">Offer the file AND the printed copy from the same listing. Buyer picks at checkout.</span> | |
| 286 | </button> | |
| 287 | </div> | |
| 288 | ||
| 289 | <form id="upForm" method="post" action="/upload_model.cgi" enctype="multipart/form-data" data-up-demo="$is_demo"> | |
| 290 | <!-- _action is set by the clicked submit button so that "Save draft" | |
| 291 | and "Publish" send distinct values. Do NOT add a hidden _action | |
| 292 | here -- it would get joined with the button value by CGI->Vars, | |
| 293 | producing "publish\0draft" and breaking the draft path. --> | |
| 294 | [if:$is_edit]<input type="hidden" name="model_id" value="$edit_id">[/if] | |
| 295 | ||
| 296 | <input type="hidden" name="highlights_json" id="upHighlightsJson" value=""> | |
| 297 | <input type="hidden" name="included_json" id="upIncludedJson" value=""> | |
| 298 | <input type="hidden" name="videos_json" id="upVideosJson" value=""> | |
| 299 | <input type="hidden" name="product_kind" id="upProductKind" value="$product_kind"> | |
| 300 | ||
| 301 | <!-- Form-kind banner. The three title spans and three icons all live | |
| 302 | in the DOM; CSS shows only the one matching the body kind class | |
| 303 | that applyKind() sets. No JS branching needed for swap. --> | |
| 304 | <div class="up-form-header" id="upFormHeader"> | |
| 305 | <span class="up-form-header-icon"> | |
| 306 | <svg class="ufh-d" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> | |
| 307 | <svg class="ufh-p" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> | |
| 308 | <svg class="ufh-b" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg> | |
| 309 | </span> | |
| 310 | <div> | |
| 311 | <div class="up-form-header-title"> | |
| 312 | Creating a | |
| 313 | <strong class="ufh-d">digital file</strong><strong class="ufh-p">3D-printed item</strong><strong class="ufh-b">digital file + 3D-printed item</strong> | |
| 314 | listing | |
| 315 | </div> | |
| 316 | <div class="up-form-header-sub"> | |
| 317 | <span class="ufh-d">Buyers download the file instantly after checkout.</span> | |
| 318 | <span class="ufh-p">You print and ship the finished item to the buyer.</span> | |
| 319 | <span class="ufh-b">Buyer chooses at checkout — file, printed item, or both.</span> | |
| 320 | </div> | |
| 321 | </div> | |
| 322 | </div> | |
| 323 | ||
| 324 | <div class="dash-grid up-grid" style="grid-template-columns:2fr 1fr"> | |
| 325 | ||
| 326 | <!-- ================= LEFT MAIN ================= --> | |
| 327 | <div style="display:flex;flex-direction:column;gap:16px"> | |
| 328 | ||
| 329 | <!-- Section 01: Gallery Images --> | |
| 330 | <div class="module"> | |
| 331 | <div class="module-head"> | |
| 332 | <div class="left"> | |
| 333 | <span class="up-section-num">01</span> | |
| 334 | <div> | |
| 335 | <div class="module-title">Gallery images</div> | |
| 336 | <div class="module-sub">First image becomes the cover. Max 20 images, 10 MB each. Recommended 1200×900.</div> | |
| 337 | </div> | |
| 338 | </div> | |
| 339 | </div> | |
| 340 | <div class="module-body"> | |
| 341 | [if:$has_existing_images] | |
| 342 | <div class="up-img-grid" style="margin-bottom:12px"> | |
| 343 | [loop:@existing_images] | |
| 344 | <div class="up-img-thumb[if:$loop1.is_primary] is-cover[/if]" data-existing-img-id="$loop1.id"> | |
| 345 | <img src="$loop1.url" alt=""> | |
| 346 | [if:$loop1.is_primary]<span class="up-img-badge">Cover</span>[/if] | |
| 347 | <button type="button" class="up-img-x" data-rm-img="$loop1.id" title="Remove from gallery">×</button> | |
| 348 | </div> | |
| 349 | [/loop] | |
| 350 | </div> | |
| 351 | [/if] | |
| 352 | <div class="up-dropzone" id="upImgZone"> | |
| 353 | <div class="up-dropzone-icon"> | |
| 354 | <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> | |
| 355 | </div> | |
| 356 | <div class="up-dropzone-title">[if:$has_existing_images]Drop more images to add[/if][if:!$has_existing_images]Drop images here[/if]</div> | |
| 357 | <div class="up-dropzone-sub">or <span class="browse">browse from your computer</span></div> | |
| 358 | <div class="up-dropzone-types">JPG · PNG · WEBP · GIF</div> | |
| 359 | <input type="file" id="upImgInput" name="model_images" multiple accept="image/*" hidden> | |
| 360 | </div> | |
| 361 | <div class="up-img-grid" id="upImgGrid"> | |
| 362 | [if:!$has_existing_images]<div class="up-img-empty">No images yet — the first image you add becomes the cover.</div>[/if] | |
| 363 | </div> | |
| 364 | </div> | |
| 365 | </div> | |
| 366 | ||
| 367 | <!-- Section 02: 3D Files --> | |
| 368 | <div class="module" id="upFilesModule"> | |
| 369 | <div class="module-head"> | |
| 370 | <div class="left"> | |
| 371 | <span class="up-section-num">02</span> | |
| 372 | <div> | |
| 373 | <div class="module-title">3D print files</div> | |
| 374 | <div class="module-sub">STL, OBJ, STEP, STP, 3MF, ZIP — up to 500 MB each, 2 GB total.</div> | |
| 375 | </div> | |
| 376 | </div> | |
| 377 | </div> | |
| 378 | <div class="module-body"> | |
| 379 | [if:$has_existing_files] | |
| 380 | <div class="up-file-list" style="margin-bottom:12px"> | |
| 381 | [loop:@existing_files] | |
| 382 | <div class="up-file-item" data-existing-file-id="$loop1.id"> | |
| 383 | <div class="up-file-ext">$loop1.ext</div> | |
| 384 | <div class="up-file-info"> | |
| 385 | <div class="up-file-name">$loop1.name</div> | |
| 386 | <div class="up-file-meta">$loop1.size_kb KB · already attached</div> | |
| 387 | </div> | |
| 388 | <button type="button" class="up-file-remove" data-rm-file="$loop1.id" title="Remove from this model">×</button> | |
| 389 | </div> | |
| 390 | [/loop] | |
| 391 | </div> | |
| 392 | [/if] | |
| 393 | <div class="up-dropzone" id="upFileZone"> | |
| 394 | <div class="up-dropzone-icon"> | |
| 395 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> | |
| 396 | </div> | |
| 397 | <div class="up-dropzone-title">[if:$is_edit]Drop more files to add[/if][if:!$is_edit]Drop your 3D files here[/if]</div> | |
| 398 | <div class="up-dropzone-sub">or <span class="browse">browse from your computer</span></div> | |
| 399 | <div class="up-dropzone-types">STL · OBJ · STEP · STP · 3MF · ZIP</div> | |
| 400 | <input type="file" id="upFileInput" name="model_files" multiple accept=".stl,.obj,.step,.stp,.3mf,.zip" hidden> | |
| 401 | </div> | |
| 402 | <div class="up-file-list" id="upFileList"></div> | |
| 403 | ||
| 404 | <div class="up-check-grid" style="margin-top:14px"> | |
| 405 | <label class="up-check"><input type="checkbox" name="opt_pre_supported" value="1" $chk_opt_pre_supported> Pre-generated supports</label> | |
| 406 | <label class="up-check"><input type="checkbox" name="opt_support_free" value="1" $chk_opt_support_free> Support-free design</label> | |
| 407 | <label class="up-check"><input type="checkbox" name="opt_slicer_profiles" value="1" $chk_opt_slicer_profiles> Slicer profiles included</label> | |
| 408 | <label class="up-check"><input type="checkbox" name="opt_assembly_guide" value="1" $chk_opt_assembly_guide> Assembly / print guide</label> | |
| 409 | </div> | |
| 410 | </div> | |
| 411 | </div> | |
| 412 | ||
| 413 | <!-- Section 03: Video Links --> | |
| 414 | <div class="module"> | |
| 415 | <div class="module-head"> | |
| 416 | <div class="left"> | |
| 417 | <span class="up-section-num">03</span> | |
| 418 | <div> | |
| 419 | <div class="module-title">Video links</div> | |
| 420 | <div class="module-sub">YouTube, Vimeo, or other — show your model in action. Optional. Up to 6.</div> | |
| 421 | </div> | |
| 422 | </div> | |
| 423 | </div> | |
| 424 | <div class="module-body"> | |
| 425 | <div class="up-row-list" id="upVideoList"> | |
| 426 | [if:$has_videos] | |
| 427 | [loop:@videos_arr] | |
| 428 | <div class="up-row"> | |
| 429 | <span class="icon">▶</span> | |
| 430 | <input type="url" class="input" placeholder="https://youtube.com/watch?v=…" value="$loop1.text"> | |
| 431 | <button type="button" class="up-row-x" aria-label="Remove">×</button> | |
| 432 | </div> | |
| 433 | [/loop] | |
| 434 | [/if] | |
| 435 | [if:!$has_videos] | |
| 436 | <div class="up-row"> | |
| 437 | <span class="icon">▶</span> | |
| 438 | <input type="url" class="input" placeholder="https://youtube.com/watch?v=…"> | |
| 439 | <button type="button" class="up-row-x" disabled aria-label="Remove">×</button> | |
| 440 | </div> | |
| 441 | [/if] | |
| 442 | </div> | |
| 443 | <button type="button" class="up-add-btn" id="upAddVideo">+ Add video</button> | |
| 444 | </div> | |
| 445 | </div> | |
| 446 | ||
| 447 | <!-- Section 04: Key Highlights --> | |
| 448 | <div class="module"> | |
| 449 | <div class="module-head"> | |
| 450 | <div class="left"> | |
| 451 | <span class="up-section-num">04</span> | |
| 452 | <div> | |
| 453 | <div class="module-title">Key highlights</div> | |
| 454 | <div class="module-sub">Up to 8 selling points shown prominently on your listing. What makes this model special?</div> | |
| 455 | </div> | |
| 456 | </div> | |
| 457 | </div> | |
| 458 | <div class="module-body"> | |
| 459 | <div class="up-row-list" id="upHighlightList"> | |
| 460 | [if:$has_highlights] | |
| 461 | [loop:@highlights_arr] | |
| 462 | <div class="up-row"> | |
| 463 | <span class="icon">✦</span> | |
| 464 | <input type="text" class="input" placeholder="e.g. Pre-supported — no manual setup needed" maxlength="120" value="$loop1.text"> | |
| 465 | <button type="button" class="up-row-x" aria-label="Remove">×</button> | |
| 466 | </div> | |
| 467 | [/loop] | |
| 468 | [/if] | |
| 469 | [if:!$has_highlights] | |
| 470 | <div class="up-row"> | |
| 471 | <span class="icon">✦</span> | |
| 472 | <input type="text" class="input" placeholder="e.g. Pre-supported — no manual setup needed" maxlength="120"> | |
| 473 | <button type="button" class="up-row-x" disabled aria-label="Remove">×</button> | |
| 474 | </div> | |
| 475 | [/if] | |
| 476 | </div> | |
| 477 | <button type="button" class="up-add-btn" id="upAddHighlight">+ Add highlight</button> | |
| 478 | </div> | |
| 479 | </div> | |
| 480 | ||
| 481 | <!-- Section 05: What's Included --> | |
| 482 | <div class="module"> | |
| 483 | <div class="module-head"> | |
| 484 | <div class="left"> | |
| 485 | <span class="up-section-num">05</span> | |
| 486 | <div> | |
| 487 | <div class="module-title">What's included</div> | |
| 488 | <div class="module-sub">List everything the buyer receives — file counts, formats, guides, profiles, extras.</div> | |
| 489 | </div> | |
| 490 | </div> | |
| 491 | </div> | |
| 492 | <div class="module-body"> | |
| 493 | <div class="up-row-list" id="upIncludedList"> | |
| 494 | [if:$has_included] | |
| 495 | [loop:@included_arr] | |
| 496 | <div class="up-row"> | |
| 497 | <span class="icon">✓</span> | |
| 498 | <input type="text" class="input" placeholder="e.g. 12 × STL files — one per part, named and numbered" maxlength="140" value="$loop1.text"> | |
| 499 | <button type="button" class="up-row-x" aria-label="Remove">×</button> | |
| 500 | </div> | |
| 501 | [/loop] | |
| 502 | [/if] | |
| 503 | [if:!$has_included] | |
| 504 | <div class="up-row"> | |
| 505 | <span class="icon">✓</span> | |
| 506 | <input type="text" class="input" placeholder="e.g. 12 × STL files — one per part, named and numbered" maxlength="140"> | |
| 507 | <button type="button" class="up-row-x" disabled aria-label="Remove">×</button> | |
| 508 | </div> | |
| 509 | [/if] | |
| 510 | </div> | |
| 511 | <button type="button" class="up-add-btn" id="upAddIncluded">+ Add item</button> | |
| 512 | </div> | |
| 513 | </div> | |
| 514 | ||
| 515 | </div> | |
| 516 | ||
| 517 | <!-- ================= RIGHT SIDEBAR ================= --> | |
| 518 | <div style="display:flex;flex-direction:column;gap:16px"> | |
| 519 | ||
| 520 | <!-- Basic info --> | |
| 521 | <div class="module"> | |
| 522 | <div class="module-head"><div class="left"> | |
| 523 | <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> | |
| 524 | <div><div class="module-title">Basic info</div><div class="module-sub">Canonical fields, used everywhere.</div></div> | |
| 525 | </div></div> | |
| 526 | <div class="module-body"> | |
| 527 | <div class="form-group"> | |
| 528 | <label class="form-label">Title <span class="req">*</span></label> | |
| 529 | <input type="text" name="title" class="input" maxlength="180" required placeholder="e.g. Celestial Mech Suit" value="$title"> | |
| 530 | </div> | |
| 531 | <div class="form-group"> | |
| 532 | <label class="form-label">Short tagline</label> | |
| 533 | <input type="text" name="tagline" class="input" maxlength="160" placeholder="One-line summary shown on listing cards" value="$tagline"> | |
| 534 | </div> | |
| 535 | <div class="form-group"> | |
| 536 | <label class="form-label">Full description <span class="req">*</span></label> | |
| 537 | <textarea name="description" class="textarea" rows="6" required placeholder="Describe your model — assembly, materials, scale, compatible printers, what it's for…">$description</textarea> | |
| 538 | </div> | |
| 539 | <div class="form-group"> | |
| 540 | <label class="form-label">Category <span class="req">*</span></label> | |
| 541 | <select name="category" class="select" required> | |
| 542 | <option value="">— Select —</option> | |
| 543 | $category_options_html | |
| 544 | </select> | |
| 545 | </div> | |
| 546 | <div class="form-group"> | |
| 547 | <label class="form-label">Tags <span class="text-xs text-dim">(comma separated)</span></label> | |
| 548 | <input type="text" name="tags" class="input" placeholder="e.g. armor, sci-fi, articulated, 28mm" value="$tags"> | |
| 549 | </div> | |
| 550 | ||
| 551 | [if:$has_seller_categories] | |
| 552 | <div class="form-group"> | |
| 553 | <label class="form-label">Your collections <span class="text-xs text-dim">(<a href="/categories.cgi" target="_blank" class="text-brand">manage</a>)</span></label> | |
| 554 | <div style="display:flex;flex-wrap:wrap;gap:6px;padding:4px 0"> | |
| 555 | [loop:@seller_categories] | |
| 556 | <label style="display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:$loop1.color;color:#fff;font-size:12px;font-weight:600;cursor:pointer;opacity:0.55;transition:opacity .15s" class="up-cat-chip"> | |
| 557 | <input type="checkbox" name="seller_cat_ids" value="$loop1.id" $loop1.checked style="display:none"> | |
| 558 | [if:$loop1.icon]<span style="font-size:20px;line-height:1;transform:translateY(-1px);display:inline-block">$loop1.icon</span>[/if] | |
| 559 | <span>$loop1.name</span> | |
| 560 | </label> | |
| 561 | [/loop] | |
| 562 | </div> | |
| 563 | </div> | |
| 564 | [/if] | |
| 565 | [if:!$has_seller_categories] | |
| 566 | <div class="form-group"> | |
| 567 | <p class="text-xs text-dim" style="margin:0;line-height:1.5"> | |
| 568 | Want to group this with your own collections? <a href="/categories.cgi" target="_blank" class="text-brand">Build categories →</a> | |
| 569 | </p> | |
| 570 | </div> | |
| 571 | [/if] | |
| 572 | </div> | |
| 573 | </div> | |
| 574 | ||
| 575 | <!-- Pricing --> | |
| 576 | <div class="module"> | |
| 577 | <div class="module-head"><div class="left"> | |
| 578 | <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> | |
| 579 | <div><div class="module-title">Pricing</div><div class="module-sub">Free release or paid listing.</div></div> | |
| 580 | </div></div> | |
| 581 | <div class="module-body"> | |
| 582 | <div class="up-price-toggle"> | |
| 583 | <button type="button" class="$price_free_active" id="upPriceFree">Free</button> | |
| 584 | <button type="button" class="$price_paid_active" id="upPricePaid">Paid</button> | |
| 585 | </div> | |
| 586 | <input type="hidden" name="price_mode" id="upPriceMode" value="$price_mode"> | |
| 587 | <div class="up-price-input" id="upPriceWrap" style="$price_wrap_style"> | |
| 588 | <span class="currency">\$</span> | |
| 589 | <input type="number" name="price" class="input" placeholder="0.00" min="0.99" step="0.01" value="$price"> | |
| 590 | </div> | |
| 591 | <p class="text-xs text-dim" id="upPriceNote" style="margin-top:8px;line-height:1.5">WebSTLs takes a 15% platform fee on paid sales. You keep 85%.</p> | |
| 592 | </div> | |
| 593 | </div> | |
| 594 | ||
| 595 | <!-- Physical product details. Hidden when product_kind=digital; | |
| 596 | JS toggles via #upKindTabs. All fields are optional except | |
| 597 | the price -- the seller may not know weight/dims yet, and | |
| 598 | shipping rates default to "free" if blank. --> | |
| 599 | <div class="module" id="upPhysicalPanel" style="$physical_panel_style"> | |
| 600 | <div class="module-head"><div class="left"> | |
| 601 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg></div> | |
| 602 | <div><div class="module-title">Physical print details</div><div class="module-sub">Stock, dimensions, shipping — what you need to print and ship the order.</div></div> | |
| 603 | </div></div> | |
| 604 | <div class="module-body"> | |
| 605 | <div class="form-group"> | |
| 606 | <label class="form-label">Printed price <span class="req">*</span></label> | |
| 607 | <div class="up-price-input"> | |
| 608 | <span class="currency">\$</span> | |
| 609 | <input type="number" name="physical_price" class="input" placeholder="0.00" min="0.01" step="0.01" value="$physical_price"> | |
| 610 | </div> | |
| 611 | <p class="text-xs text-dim" style="margin-top:6px;line-height:1.5">What buyers pay for the printed copy. Shipping is added at checkout.</p> | |
| 612 | </div> | |
| 613 | ||
| 614 | <div class="form-group"> | |
| 615 | <label class="form-label">Stock on hand</label> | |
| 616 | <input type="number" name="inventory_qty_input" class="input" placeholder="Leave blank for print-to-order" min="0" step="1" value="$inventory_qty_input"> | |
| 617 | <p class="text-xs text-dim" style="margin-top:6px;line-height:1.5">Blank means you'll print every order as it comes in. Enter a number to limit stock — sales decrement automatically.</p> | |
| 618 | </div> | |
| 619 | ||
| 620 | <div class="form-group"> | |
| 621 | <label class="form-label">Production time <span class="text-xs text-dim">(days before ship)</span></label> | |
| 622 | <input type="number" name="production_time_days" class="input" placeholder="e.g. 5" min="0" step="1" value="$production_time_days"> | |
| 623 | </div> | |
| 624 | ||
| 625 | <div class="dash-grid-2" style="gap:10px"> | |
| 626 | <div class="form-group"><label class="form-label">Weight (grams)</label><input type="number" name="weight_grams" class="input" placeholder="e.g. 240" min="0" step="1" value="$weight_grams"></div> | |
| 627 | <div class="form-group"><label class="form-label">Length (mm)</label><input type="number" name="dim_length_mm" class="input" placeholder="e.g. 180" min="0" step="1" value="$dim_length_mm"></div> | |
| 628 | <div class="form-group"><label class="form-label">Width (mm)</label><input type="number" name="dim_width_mm" class="input" placeholder="e.g. 120" min="0" step="1" value="$dim_width_mm"></div> | |
| 629 | <div class="form-group"><label class="form-label">Height (mm)</label><input type="number" name="dim_height_mm" class="input" placeholder="e.g. 90" min="0" step="1" value="$dim_height_mm"></div> | |
| 630 | </div> | |
| 631 | ||
| 632 | <div class="dash-grid-2" style="gap:10px"> | |
| 633 | <div class="form-group"><label class="form-label">Ships from (country)</label><input type="text" name="ship_from_country" class="input" placeholder="e.g. US" maxlength="2" value="$ship_from_country" style="text-transform:uppercase"></div> | |
| 634 | <div class="form-group"><label class="form-label">Ships from (postal)</label><input type="text" name="ship_from_postal" class="input" placeholder="e.g. 90210" maxlength="20" value="$ship_from_postal"></div> | |
| 635 | </div> | |
| 636 | ||
| 637 | <div class="form-group"> | |
| 638 | <label class="form-label">Shipping rates</label> | |
| 639 | <textarea name="shipping_options" class="textarea" rows="3" placeholder="domestic=900 worldwide=2200" style="font-family:var(--font-mono,monospace);font-size:12px">$shipping_options</textarea> | |
| 640 | <p class="text-xs text-dim" style="margin-top:6px;line-height:1.5">One zone per line as <code>zone=cents</code>. Buyer picks at checkout. Blank means free shipping.</p> | |
| 641 | </div> | |
| 642 | ||
| 643 | <div class="form-group"> | |
| 644 | <label class="form-label">Colors offered <span class="text-xs text-dim">(comma separated)</span></label> | |
| 645 | <input type="text" name="color_options" class="input" placeholder="e.g. Black, White, Red, Translucent Blue" value="$color_options"> | |
| 646 | </div> | |
| 647 | <div class="form-group"> | |
| 648 | <label class="form-label">Materials offered <span class="text-xs text-dim">(comma separated)</span></label> | |
| 649 | <input type="text" name="material_options" class="input" placeholder="e.g. PLA, PETG, Resin" value="$material_options"> | |
| 650 | </div> | |
| 651 | </div> | |
| 652 | </div> | |
| 653 | ||
| 654 | <!-- Specs --> | |
| 655 | <div class="module"> | |
| 656 | <div class="module-head"><div class="left"> | |
| 657 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z"/><path d="m3 7 9 5 9-5"/><path d="M12 22V12"/></svg></div> | |
| 658 | <div><div class="module-title">Specs</div><div class="module-sub">Quick facts buyers scan first.</div></div> | |
| 659 | </div></div> | |
| 660 | <div class="module-body"> | |
| 661 | <div class="dash-grid-2" style="gap:10px"> | |
| 662 | <div class="form-group"><label class="form-label">Parts</label><input type="number" name="spec_parts" class="input" placeholder="e.g. 12" min="1" value="$spec_parts"></div> | |
| 663 | <div class="form-group"><label class="form-label">Filament weight</label><input type="text" name="spec_weight" class="input" placeholder="e.g. ~150 g" value="$spec_weight"></div> | |
| 664 | <div class="form-group"><label class="form-label">Est. print time</label><input type="text" name="spec_time" class="input" placeholder="e.g. ~8 hrs" value="$spec_time"></div> | |
| 665 | <div class="form-group"> | |
| 666 | <label class="form-label">Difficulty</label> | |
| 667 | <select name="spec_difficulty" class="select"> | |
| 668 | <option value="">Select…</option> | |
| 669 | $difficulty_options_html | |
| 670 | </select> | |
| 671 | </div> | |
| 672 | <div class="form-group"> | |
| 673 | <label class="form-label">Supports</label> | |
| 674 | <select name="spec_supports" class="select"> | |
| 675 | <option value="">Select…</option> | |
| 676 | $supports_options_html | |
| 677 | </select> | |
| 678 | </div> | |
| 679 | <div class="form-group"><label class="form-label">File formats</label><input type="text" name="spec_formats" class="input" placeholder="e.g. STL + OBJ" value="$spec_formats"></div> | |
| 680 | </div> | |
| 681 | </div> | |
| 682 | </div> | |
| 683 | ||
| 684 | <!-- Print settings --> | |
| 685 | <div class="module"> | |
| 686 | <div class="module-head"><div class="left"> | |
| 687 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 22 22 22 12 2"/></svg></div> | |
| 688 | <div><div class="module-title">Suggested print settings</div><div class="module-sub">Helps buyers get a good first print.</div></div> | |
| 689 | </div></div> | |
| 690 | <div class="module-body"> | |
| 691 | <div class="dash-grid-2" style="gap:10px"> | |
| 692 | <div class="form-group"><label class="form-label">Layer height (mm)</label><input type="text" name="ps_layer" class="input" placeholder="e.g. 0.15" value="$ps_layer"></div> | |
| 693 | <div class="form-group"><label class="form-label">Infill (%)</label><input type="text" name="ps_infill" class="input" placeholder="e.g. 15–20" value="$ps_infill"></div> | |
| 694 | <div class="form-group"><label class="form-label">Nozzle temp</label><input type="text" name="ps_nozzle" class="input" placeholder="e.g. 210°C PLA" value="$ps_nozzle"></div> | |
| 695 | <div class="form-group"><label class="form-label">Bed temp</label><input type="text" name="ps_bed" class="input" placeholder="e.g. 60°C" value="$ps_bed"></div> | |
| 696 | <div class="form-group"><label class="form-label">Print speed (mm/s)</label><input type="text" name="ps_speed" class="input" placeholder="e.g. 40–60" value="$ps_speed"></div> | |
| 697 | <div class="form-group"><label class="form-label">Walls / perimeters</label><input type="text" name="ps_walls" class="input" placeholder="e.g. 3–4" value="$ps_walls"></div> | |
| 698 | <div class="form-group"><label class="form-label">Min printer bed (mm)</label><input type="text" name="ps_bed_min" class="input" placeholder="e.g. 200 × 200" value="$ps_bed_min"></div> | |
| 699 | <div class="form-group"><label class="form-label">Resin layer (mm)</label><input type="text" name="ps_resin" class="input" placeholder="e.g. 0.05 (MSLA)" value="$ps_resin"></div> | |
| 700 | </div> | |
| 701 | </div> | |
| 702 | </div> | |
| 703 | ||
| 704 | <!-- Printer compatibility --> | |
| 705 | <div class="module"> | |
| 706 | <div class="module-head"><div class="left"> | |
| 707 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="2" width="12" height="8" rx="1"/><path d="M6 18h12v4H6z"/><path d="M6 10v8h12v-8"/></svg></div> | |
| 708 | <div><div class="module-title">Printer compatibility</div><div class="module-sub">Pick all that apply.</div></div> | |
| 709 | </div></div> | |
| 710 | <div class="module-body"> | |
| 711 | <div class="up-check-grid"> | |
| 712 | <label class="up-check"><input type="checkbox" name="compat_fdm" value="1" $chk_compat_fdm> FDM / FFF</label> | |
| 713 | <label class="up-check"><input type="checkbox" name="compat_resin" value="1" $chk_compat_resin> Resin / MSLA</label> | |
| 714 | <label class="up-check"><input type="checkbox" name="compat_sla" value="1" $chk_compat_sla> SLA</label> | |
| 715 | <label class="up-check"><input type="checkbox" name="compat_sls" value="1" $chk_compat_sls> SLS / powder</label> | |
| 716 | <label class="up-check"><input type="checkbox" name="compat_mmu" value="1" $chk_compat_mmu> Multi-material (MMU)</label> | |
| 717 | <label class="up-check"><input type="checkbox" name="compat_large" value="1" $chk_compat_large> Large format</label> | |
| 718 | </div> | |
| 719 | <div class="form-group" style="margin-top:12px"> | |
| 720 | <label class="form-label">Tested printers <span class="text-xs text-dim">(optional)</span></label> | |
| 721 | <input type="text" name="tested_printers" class="input" placeholder="e.g. Bambu X1C, Prusa MK4, Elegoo Saturn 3" value="$tested_printers"> | |
| 722 | </div> | |
| 723 | </div> | |
| 724 | </div> | |
| 725 | ||
| 726 | <!-- Materials --> | |
| 727 | <div class="module"> | |
| 728 | <div class="module-head"><div class="left"> | |
| 729 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/></svg></div> | |
| 730 | <div><div class="module-title">Recommended materials</div><div class="module-sub">What you've tested it in.</div></div> | |
| 731 | </div></div> | |
| 732 | <div class="module-body"> | |
| 733 | <div class="up-check-grid"> | |
| 734 | <label class="up-check"><input type="checkbox" name="mat_pla" value="1" $chk_mat_pla> PLA</label> | |
| 735 | <label class="up-check"><input type="checkbox" name="mat_petg" value="1" $chk_mat_petg> PETG</label> | |
| 736 | <label class="up-check"><input type="checkbox" name="mat_abs" value="1" $chk_mat_abs> ABS / ASA</label> | |
| 737 | <label class="up-check"><input type="checkbox" name="mat_tpu" value="1" $chk_mat_tpu> TPU / flexible</label> | |
| 738 | <label class="up-check"><input type="checkbox" name="mat_resin_std" value="1" $chk_mat_resin_std> Resin (standard)</label> | |
| 739 | <label class="up-check"><input type="checkbox" name="mat_resin_abs" value="1" $chk_mat_resin_abs> Resin (ABS-like)</label> | |
| 740 | <label class="up-check"><input type="checkbox" name="mat_nylon" value="1" $chk_mat_nylon> Nylon / PA</label> | |
| 741 | <label class="up-check"><input type="checkbox" name="mat_cf" value="1" $chk_mat_cf> Carbon fiber</label> | |
| 742 | </div> | |
| 743 | </div> | |
| 744 | </div> | |
| 745 | ||
| 746 | <!-- License --> | |
| 747 | <div class="module"> | |
| 748 | <div class="module-head"><div class="left"> | |
| 749 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div> | |
| 750 | <div><div class="module-title">License</div><div class="module-sub">What buyers can do after purchase.</div></div> | |
| 751 | </div></div> | |
| 752 | <div class="module-body"> | |
| 753 | <select name="license_type" class="select"> | |
| 754 | $license_options_html | |
| 755 | </select> | |
| 756 | </div> | |
| 757 | </div> | |
| 758 | ||
| 759 | <!-- Visibility --> | |
| 760 | <div class="module"> | |
| 761 | <div class="module-head"><div class="left"> | |
| 762 | <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> | |
| 763 | <div><div class="module-title">Visibility</div><div class="module-sub">Who can find this listing.</div></div> | |
| 764 | </div></div> | |
| 765 | <div class="module-body"> | |
| 766 | <div class="up-sched-options"> | |
| 767 | <label><input type="radio" name="visibility" value="public" $sel_vis_public> Public — visible to everyone</label> | |
| 768 | <label><input type="radio" name="visibility" value="unlisted" $sel_vis_unlisted> Unlisted — direct link only</label> | |
| 769 | <label><input type="radio" name="visibility" value="private" $sel_vis_private> Private — only visible to you</label> | |
| 770 | </div> | |
| 771 | </div> | |
| 772 | </div> | |
| 773 | ||
| 774 | <!-- Marketplaces (Publish targets + demo limit) --> | |
| 775 | <div class="module"> | |
| 776 | <div class="module-head"><div class="left"> | |
| 777 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7Z"/></svg></div> | |
| 778 | <div><div class="module-title">Publish to</div><div class="module-sub">Your storefront, plus the marketplaces you pick.</div></div> | |
| 779 | </div></div> | |
| 780 | <div class="module-body" style="display:flex;flex-direction:column;gap:8px"> | |
| 781 | ||
| 782 | <label class="up-mp-row is-checked" style="opacity:1;cursor:default"> | |
| 783 | <span class="up-mp-icon" style="background:linear-gradient(130deg,#10b981,#34d399)">W</span> | |
| 784 | <span class="up-mp-name">affiliate.3dshawn.com<br><span class="up-mp-status" style="color:#10b981">Your storefront · always on</span></span> | |
| 785 | <input type="checkbox" name="publish_webstls" value="1" checked disabled> | |
| 786 | <input type="hidden" name="publish_webstls" value="1"> | |
| 787 | </label> | |
| 788 | ||
| 789 | [loop:@marketplaces] | |
| 790 | <label class="up-mp-row" data-mp-tile data-slug="$loop1.slug" data-sells-digital="$loop1.sells_digital" data-sells-physical="$loop1.sells_physical"> | |
| 791 | <span class="up-mp-icon">$loop1.icon</span> | |
| 792 | <span class="up-mp-name">$loop1.name<br><span class="up-mp-status">$loop1.fees</span></span> | |
| 793 | <input type="checkbox" name="publish_to[]" value="$loop1.slug" class="up-mp-check"> | |
| 794 | </label> | |
| 795 | [/loop] | |
| 796 | ||
| 797 | [if:$is_demo] | |
| 798 | <div class="up-demo-counter" id="upDemoCounter"> | |
| 799 | <span>Demo mode — pick up to <strong>2</strong> marketplaces (<span id="upDemoCount">0</span>/2 selected)</span> | |
| 800 | <a href="/billing.cgi" class="up-upgrade">Upgrade</a> | |
| 801 | </div> | |
| 802 | [/if] | |
| 803 | ||
| 804 | <div class="banner info" style="margin-top:8px"> | |
| 805 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> | |
| 806 | <div class="text-xs" style="line-height:1.5">Marketplaces without live adapters yet are queued as manual exports — you'll get a paste-ready listing to push from your dashboard.</div> | |
| 807 | </div> | |
| 808 | </div> | |
| 809 | </div> | |
| 810 | ||
| 811 | <!-- Schedule --> | |
| 812 | <div class="module"> | |
| 813 | <div class="module-head"><div class="left"> | |
| 814 | <div class="module-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div> | |
| 815 | <div><div class="module-title">Schedule</div><div class="module-sub">Publish now or later.</div></div> | |
| 816 | </div></div> | |
| 817 | <div class="module-body"> | |
| 818 | <div class="up-sched-options" id="upSchedOpts"> | |
| 819 | <label><input type="radio" name="schedule_mode" value="now" $sel_sched_now> Publish immediately</label> | |
| 820 | <label><input type="radio" name="schedule_mode" value="scheduled" $sel_sched_scheduled> Schedule for a later date/time</label> | |
| 821 | <label><input type="radio" name="schedule_mode" value="patreon_first" $sel_sched_patreon_first> Drop to Patreon first, others 24h later</label> | |
| 822 | </div> | |
| 823 | <div class="up-sched-when" id="upSchedWhen"> | |
| 824 | <input type="datetime-local" name="scheduled_at" class="input" value="$scheduled_at"> | |
| 825 | </div> | |
| 826 | </div> | |
| 827 | </div> | |
| 828 | ||
| 829 | <!-- Actions --> | |
| 830 | <div class="module"> | |
| 831 | <div class="module-body" style="display:flex;flex-direction:column;gap:8px"> | |
| 832 | <button type="submit" name="_action" value="publish" class="btn btn-primary" style="width:100%"> | |
| 833 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7Z"/></svg> | |
| 834 | [if:$is_edit]Save changes[/if][if:!$is_edit]Publish listing[/if] | |
| 835 | </button> | |
| 836 | [if:!$is_edit] | |
| 837 | <button type="submit" name="_action" value="draft" class="btn btn-secondary" style="width:100%">Save as draft</button> | |
| 838 | [/if] | |
| 839 | [if:$is_edit] | |
| 840 | <a href="/models.cgi" class="btn btn-secondary" style="width:100%;text-align:center">Cancel</a> | |
| 841 | [/if] | |
| 842 | <p class="text-xs text-dim" style="margin:6px 0 0;line-height:1.5;text-align:center">By publishing you agree to the <a href="/legal/seller-terms.html" target="_blank">WebSTLs Seller Terms</a> and confirm you hold rights to all uploaded content.</p> | |
| 843 | </div> | |
| 844 | </div> | |
| 845 | ||
| 846 | [if:$is_edit] | |
| 847 | <!-- Destructive: soft-delete the model. Separate module so the | |
| 848 | button is visually divorced from Save/Cancel above. --> | |
| 849 | <div class="module" style="border-color:rgba(239,68,68,0.25)"> | |
| 850 | <div class="module-head"><div class="left"> | |
| 851 | <div class="module-icon" style="background:rgba(239,68,68,0.14);color:#f87171"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></div> | |
| 852 | <div><div class="module-title" style="color:#f87171">Danger zone</div><div class="module-sub">Removes the model from your storefront and every marketplace push.</div></div> | |
| 853 | </div></div> | |
| 854 | <div class="module-body"> | |
| 855 | <button type="button" id="upDeleteModel" data-model-id="$edit_id" style="width:100%;background:rgba(239,68,68,0.10);border:1px solid rgba(239,68,68,0.35);color:#f87171;padding:11px;border-radius:8px;cursor:pointer;font-weight:700;letter-spacing:1px;text-transform:uppercase;font-family:inherit;font-size:12px;transition:all 0.15s"> | |
| 856 | × Delete model | |
| 857 | </button> | |
| 858 | <p class="text-xs text-dim" style="margin:8px 0 0;line-height:1.5">The model moves to your Trash. Buyers can no longer see it the moment you confirm. Open the Trash tab on the <a href="/models.cgi?filter=trash" class="text-brand">Models page</a> to restore it later.</p> | |
| 859 | </div> | |
| 860 | </div> | |
| 861 | [/if] | |
| 862 | ||
| 863 | </div> | |
| 864 | </div> | |
| 865 | ||
| 866 | </form> | |
| 867 | ||
| 868 | ||
| 869 | <script> | |
| 870 | /* ===================================================================== | |
| 871 | Upload page interactions. All vanilla — no third-party deps. | |
| 872 | ===================================================================== */ | |
| 873 | (function() { | |
| 874 | ||
| 875 | /* ---- 3D file dropzone ---- */ | |
| 876 | setupDropzone('upFileZone', 'upFileInput', renderFileList); | |
| 877 | var files = []; | |
| 878 | function renderFileList() { | |
| 879 | var list = document.getElementById('upFileList'); | |
| 880 | if (!files.length) { list.innerHTML = ''; return; } | |
| 881 | var html = ''; | |
| 882 | files.forEach(function(f, i) { | |
| 883 | var ext = (f.name.split('.').pop() || '').toUpperCase(); | |
| 884 | html += '<div class="up-file-item">' | |
| 885 | + '<div class="up-file-ext">' + ext + '</div>' | |
| 886 | + '<div class="up-file-info">' | |
| 887 | + '<div class="up-file-name">' + escapeHtml(f.name) + '</div>' | |
| 888 | + '<div class="up-file-meta">' + formatBytes(f.size) + '</div>' | |
| 889 | + '</div>' | |
| 890 | + '<button type="button" class="up-file-remove" data-i="' + i + '" aria-label="Remove">×</button>' | |
| 891 | + '</div>'; | |
| 892 | }); | |
| 893 | list.innerHTML = html; | |
| 894 | list.querySelectorAll('.up-file-remove').forEach(function(b) { | |
| 895 | b.addEventListener('click', function() { | |
| 896 | files.splice(parseInt(b.dataset.i, 10), 1); | |
| 897 | renderFileList(); | |
| 898 | syncFileInput(); | |
| 899 | }); | |
| 900 | }); | |
| 901 | } | |
| 902 | function syncFileInput() { | |
| 903 | // Rebuild the FileList so submission carries only kept files. | |
| 904 | var input = document.getElementById('upFileInput'); | |
| 905 | var dt = new DataTransfer(); | |
| 906 | files.forEach(function(f) { dt.items.add(f); }); | |
| 907 | input.files = dt.files; | |
| 908 | } | |
| 909 | function setupDropzone(zoneId, inputId, onChange) { | |
| 910 | var zone = document.getElementById(zoneId); | |
| 911 | var input = document.getElementById(inputId); | |
| 912 | zone.addEventListener('click', function() { input.click(); }); | |
| 913 | zone.addEventListener('dragover', function(e) { e.preventDefault(); zone.classList.add('drag-over'); }); | |
| 914 | zone.addEventListener('dragleave', function() { zone.classList.remove('drag-over'); }); | |
| 915 | zone.addEventListener('drop', function(e) { | |
| 916 | e.preventDefault(); zone.classList.remove('drag-over'); | |
| 917 | handleFiles(Array.from(e.dataTransfer.files)); | |
| 918 | }); | |
| 919 | input.addEventListener('change', function() { | |
| 920 | handleFiles(Array.from(input.files)); | |
| 921 | }); | |
| 922 | function handleFiles(newOnes) { | |
| 923 | if (zoneId === 'upImgZone') { | |
| 924 | addImages(newOnes.filter(function(f) { return f.type.indexOf('image/') === 0; })); | |
| 925 | } else { | |
| 926 | newOnes.forEach(function(f) { files.push(f); }); | |
| 927 | syncFileInput(); | |
| 928 | onChange(); | |
| 929 | } | |
| 930 | } | |
| 931 | } | |
| 932 | ||
| 933 | /* ---- Image dropzone ---- */ | |
| 934 | setupDropzone('upImgZone', 'upImgInput', function(){}); | |
| 935 | var images = []; | |
| 936 | function addImages(newOnes) { | |
| 937 | if (images.length + newOnes.length > 20) { | |
| 938 | appToast({ message: 'Maximum 20 images allowed. Extras were dropped.', type: 'warning' }); | |
| 939 | newOnes = newOnes.slice(0, 20 - images.length); | |
| 940 | } | |
| 941 | newOnes.forEach(function(f) { | |
| 942 | var r = new FileReader(); | |
| 943 | r.onload = function(e) { | |
| 944 | images.push({ file: f, url: e.target.result }); | |
| 945 | syncImageInput(); | |
| 946 | renderImages(); | |
| 947 | }; | |
| 948 | r.readAsDataURL(f); | |
| 949 | }); | |
| 950 | } | |
| 951 | function syncImageInput() { | |
| 952 | var input = document.getElementById('upImgInput'); | |
| 953 | var dt = new DataTransfer(); | |
| 954 | images.forEach(function(i) { dt.items.add(i.file); }); | |
| 955 | input.files = dt.files; | |
| 956 | } | |
| 957 | function renderImages() { | |
| 958 | var grid = document.getElementById('upImgGrid'); | |
| 959 | if (!images.length) { | |
| 960 | grid.innerHTML = '<div class="up-img-empty">No images yet — the first image you add becomes the cover.</div>'; | |
| 961 | return; | |
| 962 | } | |
| 963 | var html = ''; | |
| 964 | images.forEach(function(img, i) { | |
| 965 | html += '<div class="up-img-thumb' + (i === 0 ? ' is-cover' : '') + '">' | |
| 966 | + '<img src="' + img.url + '" alt="">' | |
| 967 | + (i === 0 ? '<span class="up-img-badge">Cover</span>' : '') | |
| 968 | + '<button type="button" class="up-img-x" data-i="' + i + '" aria-label="Remove">×</button>' | |
| 969 | + '</div>'; | |
| 970 | }); | |
| 971 | grid.innerHTML = html; | |
| 972 | grid.querySelectorAll('.up-img-x').forEach(function(b) { | |
| 973 | b.addEventListener('click', function(e) { | |
| 974 | e.stopPropagation(); | |
| 975 | images.splice(parseInt(b.dataset.i, 10), 1); | |
| 976 | syncImageInput(); | |
| 977 | renderImages(); | |
| 978 | }); | |
| 979 | }); | |
| 980 | } | |
| 981 | ||
| 982 | /* ---- Dynamic row lists (videos, highlights, included) ---- */ | |
| 983 | setupRowList('upVideoList', 'upAddVideo', 6, '▶', 'https://youtube.com/watch?v=…', 'url'); | |
| 984 | setupRowList('upHighlightList', 'upAddHighlight', 8, '✦', 'e.g. Pre-supported — no manual setup needed', 'text'); | |
| 985 | setupRowList('upIncludedList', 'upAddIncluded', 12, '✓', 'e.g. 12 × STL files — one per part', 'text'); | |
| 986 | function setupRowList(listId, btnId, max, icon, ph, type) { | |
| 987 | var list = document.getElementById(listId); | |
| 988 | var btn = document.getElementById(btnId); | |
| 989 | update(); | |
| 990 | list.addEventListener('click', function(e) { | |
| 991 | if (e.target.classList.contains('up-row-x')) { | |
| 992 | e.target.closest('.up-row').remove(); | |
| 993 | update(); | |
| 994 | } | |
| 995 | }); | |
| 996 | btn.addEventListener('click', function() { | |
| 997 | if (list.querySelectorAll('.up-row').length >= max) { | |
| 998 | appToast({ message: 'Maximum ' + max + ' items.', type: 'warning' }); | |
| 999 | return; | |
| 1000 | } | |
| 1001 | var row = document.createElement('div'); | |
| 1002 | row.className = 'up-row'; | |
| 1003 | row.innerHTML = '<span class="icon">' + icon + '</span>' | |
| 1004 | + '<input type="' + type + '" class="input" placeholder="' + ph + '">' | |
| 1005 | + '<button type="button" class="up-row-x" aria-label="Remove">×</button>'; | |
| 1006 | list.appendChild(row); | |
| 1007 | update(); | |
| 1008 | }); | |
| 1009 | function update() { | |
| 1010 | var rows = list.querySelectorAll('.up-row'); | |
| 1011 | rows.forEach(function(r) { | |
| 1012 | r.querySelector('.up-row-x').disabled = (rows.length === 1); | |
| 1013 | }); | |
| 1014 | } | |
| 1015 | } | |
| 1016 | ||
| 1017 | /* ---- Pricing toggle ---- */ | |
| 1018 | var freeBtn = document.getElementById('upPriceFree'); | |
| 1019 | var paidBtn = document.getElementById('upPricePaid'); | |
| 1020 | var priceWrap = document.getElementById('upPriceWrap'); | |
| 1021 | var priceMode = document.getElementById('upPriceMode'); | |
| 1022 | freeBtn.addEventListener('click', function() { | |
| 1023 | freeBtn.classList.add('is-active'); paidBtn.classList.remove('is-active'); | |
| 1024 | priceWrap.style.display = 'none'; priceMode.value = 'free'; | |
| 1025 | }); | |
| 1026 | paidBtn.addEventListener('click', function() { | |
| 1027 | paidBtn.classList.add('is-active'); freeBtn.classList.remove('is-active'); | |
| 1028 | priceWrap.style.display = 'block'; priceMode.value = 'paid'; | |
| 1029 | }); | |
| 1030 | ||
| 1031 | /* ---- Product-kind tabs (Digital / Physical / Both) ---- | |
| 1032 | Swaps the hidden #upProductKind value, shows or hides the | |
| 1033 | Physical Details module, and adjusts the file dropzone copy | |
| 1034 | so a physical-only listing makes clear that file uploads are | |
| 1035 | optional (a print-on-demand seller may not want to share STLs | |
| 1036 | with the buyer at all). */ | |
| 1037 | var kindTabs = document.getElementById('upKindTabs'); | |
| 1038 | var kindInput = document.getElementById('upProductKind'); | |
| 1039 | var physPanel = document.getElementById('upPhysicalPanel'); | |
| 1040 | var pricingPanel = priceWrap && priceWrap.closest('.module'); | |
| 1041 | var pricingTitle = pricingPanel && pricingPanel.querySelector('.module-title'); | |
| 1042 | var filesModule = document.getElementById('upFilesModule'); | |
| 1043 | function applyKind(kind, animate) { | |
| 1044 | kindInput.value = kind; | |
| 1045 | kindTabs.querySelectorAll('.up-kind-tab').forEach(function(b) { | |
| 1046 | b.classList.toggle('is-active', b.dataset.kind === kind); | |
| 1047 | }); | |
| 1048 | /* Tint the rest of the form (step strip + section badges) by | |
| 1049 | swapping a kind class on <body>. CSS vars do the recoloring. */ | |
| 1050 | document.body.classList.remove('kind-digital','kind-physical','kind-both'); | |
| 1051 | document.body.classList.add('kind-' + kind); | |
| 1052 | /* On user-click only, briefly pulse the section number badges so | |
| 1053 | the seller sees the form react. Skip on initial render. */ | |
| 1054 | if (animate) { | |
| 1055 | document.querySelectorAll('.up-section-num').forEach(function(b) { | |
| 1056 | b.classList.remove('is-pulsing'); | |
| 1057 | void b.offsetWidth; | |
| 1058 | b.classList.add('is-pulsing'); | |
| 1059 | }); | |
| 1060 | } | |
| 1061 | if (physPanel) physPanel.style.display = (kind === 'digital') ? 'none' : ''; | |
| 1062 | /* Pricing module: a physical-only listing has no digital price | |
| 1063 | to set (the file is private). Hide the digital price panel | |
| 1064 | entirely so the form stays clean. Relabel for 'both' so the | |
| 1065 | seller knows that field is the file price. */ | |
| 1066 | if (pricingPanel) pricingPanel.style.display = (kind === 'physical') ? 'none' : ''; | |
| 1067 | if (pricingTitle) { | |
| 1068 | pricingTitle.textContent = (kind === 'both') ? 'Digital file price' : 'Pricing'; | |
| 1069 | } | |
| 1070 | /* 3D-files module: a print-and-ship listing has no STL to offer | |
| 1071 | the buyer. Hide the whole module so the seller doesn't think | |
| 1072 | they need to upload anything. */ | |
| 1073 | if (filesModule) filesModule.style.display = (kind === 'physical') ? 'none' : ''; | |
| 1074 | /* Marketplace tiles -- hide platforms that don't accept the | |
| 1075 | current product kind. Each tile has data-sells-digital / | |
| 1076 | data-sells-physical attributes set server-side. */ | |
| 1077 | document.querySelectorAll('[data-mp-tile]').forEach(function(tile) { | |
| 1078 | var ok = (kind === 'physical') ? tile.dataset.sellsPhysical === '1' | |
| 1079 | : (kind === 'digital') ? tile.dataset.sellsDigital === '1' | |
| 1080 | : (tile.dataset.sellsDigital === '1' || tile.dataset.sellsPhysical === '1'); | |
| 1081 | tile.style.display = ok ? '' : 'none'; | |
| 1082 | /* If we hid a tile that was checked, uncheck it so the form | |
| 1083 | doesn't queue a push to a platform that won't accept it. */ | |
| 1084 | if (!ok) { | |
| 1085 | var chk = tile.querySelector('input[type="checkbox"]'); | |
| 1086 | if (chk && chk.checked) { chk.checked = false; chk.dispatchEvent(new Event('change')); } | |
| 1087 | } | |
| 1088 | }); | |
| 1089 | } | |
| 1090 | kindTabs.addEventListener('click', function(e) { | |
| 1091 | var btn = e.target.closest('.up-kind-tab'); | |
| 1092 | if (!btn) return; | |
| 1093 | applyKind(btn.dataset.kind, true); | |
| 1094 | }); | |
| 1095 | /* Initialize from the value the server passed in -- in edit mode | |
| 1096 | we want the panel to match the saved kind on first render. */ | |
| 1097 | applyKind(kindInput.value || 'digital', false); | |
| 1098 | ||
| 1099 | /* ---- Seller-category chips: click toggles the hidden checkbox + opacity ---- */ | |
| 1100 | document.querySelectorAll('.up-cat-chip').forEach(function(chip){ | |
| 1101 | var input = chip.querySelector('input[type="checkbox"]'); | |
| 1102 | function sync() { chip.style.opacity = input.checked ? '1' : '0.55'; } | |
| 1103 | sync(); | |
| 1104 | chip.addEventListener('click', function(e){ | |
| 1105 | if (e.target === input) return; | |
| 1106 | input.checked = !input.checked; | |
| 1107 | sync(); | |
| 1108 | }); | |
| 1109 | }); | |
| 1110 | ||
| 1111 | /* ---- Schedule mode ---- */ | |
| 1112 | var schedOpts = document.getElementById('upSchedOpts'); | |
| 1113 | var schedWhen = document.getElementById('upSchedWhen'); | |
| 1114 | schedOpts.addEventListener('change', function(e) { | |
| 1115 | schedOpts.querySelectorAll('label').forEach(function(l) { | |
| 1116 | l.classList.toggle('is-selected', l.querySelector('input').checked); | |
| 1117 | }); | |
| 1118 | schedWhen.classList.toggle('is-shown', e.target.value === 'scheduled'); | |
| 1119 | }); | |
| 1120 | ||
| 1121 | /* ---- Marketplace demo limit ---- */ | |
| 1122 | var isDemo = document.getElementById('upForm').dataset.upDemo === '1'; | |
| 1123 | var demoMax = 2; | |
| 1124 | var demoCount = document.getElementById('upDemoCount'); | |
| 1125 | var mpChecks = document.querySelectorAll('.up-mp-check'); | |
| 1126 | mpChecks.forEach(function(c) { | |
| 1127 | c.addEventListener('change', function() { | |
| 1128 | var row = c.closest('.up-mp-row'); | |
| 1129 | row.classList.toggle('is-checked', c.checked); | |
| 1130 | enforceDemo(); | |
| 1131 | updateDemoCount(); | |
| 1132 | }); | |
| 1133 | }); | |
| 1134 | function enforceDemo() { | |
| 1135 | if (!isDemo) return; | |
| 1136 | var checked = document.querySelectorAll('.up-mp-check:checked').length; | |
| 1137 | mpChecks.forEach(function(c) { | |
| 1138 | if (c.checked) return; | |
| 1139 | var lock = checked >= demoMax; | |
| 1140 | c.disabled = lock; | |
| 1141 | c.closest('.up-mp-row').classList.toggle('is-locked', lock); | |
| 1142 | }); | |
| 1143 | } | |
| 1144 | function updateDemoCount() { | |
| 1145 | if (!demoCount) return; | |
| 1146 | demoCount.textContent = document.querySelectorAll('.up-mp-check:checked').length; | |
| 1147 | } | |
| 1148 | enforceDemo(); updateDemoCount(); | |
| 1149 | ||
| 1150 | /* ---- Serialize dynamic lists, then send via XHR with a progress bar ---- | |
| 1151 | The dynamic lists (highlights / included items / video links) live in | |
| 1152 | <input>s that need to be folded into hidden JSON before submit. After | |
| 1153 | that, we intercept the submit, build a FormData, and send via XHR so | |
| 1154 | the upload.progress event can drive a real progress bar. The server | |
| 1155 | still receives a normal multipart/form-data POST -- this is just a | |
| 1156 | client-side ergonomics layer. */ | |
| 1157 | var form = document.getElementById('upForm'); | |
| 1158 | form.addEventListener('submit', function (evt) { | |
| 1159 | serializeLists(); | |
| 1160 | if (!window.FormData || !window.XMLHttpRequest) return; // graceful fallback | |
| 1161 | evt.preventDefault(); | |
| 1162 | submitWithProgress(form, evt.submitter); | |
| 1163 | }); | |
| 1164 | function serializeLists() { | |
| 1165 | document.getElementById('upHighlightsJson').value = collectRows('upHighlightList'); | |
| 1166 | document.getElementById('upIncludedJson').value = collectRows('upIncludedList'); | |
| 1167 | document.getElementById('upVideosJson').value = collectRows('upVideoList'); | |
| 1168 | } | |
| 1169 | ||
| 1170 | /* ---- XHR submit + progress overlay -------------------------------- | |
| 1171 | Builds a FormData from the form, kicks off an XHR, and surfaces | |
| 1172 | a modal with two phases: | |
| 1173 | Phase 1: "Uploading" -- progress bar driven by xhr.upload.progress | |
| 1174 | Phase 2: "Saving" -- indeterminate (server is processing) | |
| 1175 | XHR follows the server's 302 redirect natively, so when the request | |
| 1176 | resolves we land on the final response URL and the browser navigates. */ | |
| 1177 | function submitWithProgress(form, submitter) { | |
| 1178 | var fd = new FormData(form); | |
| 1179 | /* Preserve the clicked submit button -- preventDefault stops the | |
| 1180 | browser from adding submitter.name/value automatically. */ | |
| 1181 | if (submitter && submitter.name) { | |
| 1182 | fd.append(submitter.name, submitter.value || ''); | |
| 1183 | } | |
| 1184 | var modal = openProgressModal(); | |
| 1185 | var totalSize = 0; | |
| 1186 | /* Sum file sizes so phase-1 "files left to upload" reads clean. */ | |
| 1187 | document.querySelectorAll('#upForm input[type="file"]').forEach(function (inp) { | |
| 1188 | for (var i = 0; i < inp.files.length; i++) totalSize += inp.files[i].size; | |
| 1189 | }); | |
| 1190 | modal.setSizeHint(totalSize); | |
| 1191 | ||
| 1192 | var xhr = new XMLHttpRequest(); | |
| 1193 | xhr.open('POST', form.action || window.location.pathname, true); | |
| 1194 | /* Tell upload_model.cgi we want a JSON-style response so we can show | |
| 1195 | progress, even though it currently responds with a 302. The | |
| 1196 | browser will follow the 302 transparently for an XHR; we read | |
| 1197 | responseURL when readystate hits DONE. */ | |
| 1198 | xhr.upload.addEventListener('progress', function (e) { | |
| 1199 | if (!e.lengthComputable) return; | |
| 1200 | modal.setUploadPct(e.loaded, e.total); | |
| 1201 | }); | |
| 1202 | xhr.upload.addEventListener('load', function () { | |
| 1203 | /* Bytes are all in the pipe. The server may still be processing | |
| 1204 | (writing to DB, queueing pushes). Switch to indeterminate. */ | |
| 1205 | modal.beginSaving(); | |
| 1206 | }); | |
| 1207 | xhr.addEventListener('load', function () { | |
| 1208 | modal.finish(); | |
| 1209 | /* XHR followed the redirect; responseURL holds the final | |
| 1210 | destination. Send the browser there. */ | |
| 1211 | var dest = xhr.responseURL || '/models.cgi'; | |
| 1212 | setTimeout(function () { window.location.href = dest; }, 250); | |
| 1213 | }); | |
| 1214 | xhr.addEventListener('error', function () { modal.fail('Upload failed. Check your connection and try again.'); }); | |
| 1215 | xhr.addEventListener('abort', function () { modal.fail('Upload canceled.'); }); | |
| 1216 | xhr.send(fd); | |
| 1217 | } | |
| 1218 | ||
| 1219 | /* Minimal modal builder. Injects once on first use. */ | |
| 1220 | function openProgressModal() { | |
| 1221 | var ov = document.createElement('div'); | |
| 1222 | ov.id = 'upProgressOverlay'; | |
| 1223 | ov.innerHTML = | |
| 1224 | '<div class="up-prog-card">' | |
| 1225 | + ' <div class="up-prog-icon">' | |
| 1226 | + ' <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2">' | |
| 1227 | + ' <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>' | |
| 1228 | + ' </svg>' | |
| 1229 | + ' </div>' | |
| 1230 | + ' <div class="up-prog-title" id="upProgTitle">Uploading your files…</div>' | |
| 1231 | + ' <div class="up-prog-sub" id="upProgSub">Sit tight — keep this tab open until it finishes.</div>' | |
| 1232 | + ' <div class="up-prog-bar"><div class="up-prog-bar-fill" id="upProgFill"></div></div>' | |
| 1233 | + ' <div class="up-prog-meta" id="upProgMeta">Preparing...</div>' | |
| 1234 | + '</div>'; | |
| 1235 | document.body.appendChild(ov); | |
| 1236 | ||
| 1237 | var fill = document.getElementById('upProgFill'); | |
| 1238 | var ttl = document.getElementById('upProgTitle'); | |
| 1239 | var sub = document.getElementById('upProgSub'); | |
| 1240 | var meta = document.getElementById('upProgMeta'); | |
| 1241 | var sizeHint = 0; | |
| 1242 | ||
| 1243 | function fmtBytes(n) { | |
| 1244 | if (!n && n !== 0) return ''; | |
| 1245 | if (n < 1024) return n + ' B'; | |
| 1246 | if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; | |
| 1247 | if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; | |
| 1248 | return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; | |
| 1249 | } | |
| 1250 | ||
| 1251 | return { | |
| 1252 | setSizeHint: function (bytes) { sizeHint = bytes; }, | |
| 1253 | setUploadPct: function (loaded, total) { | |
| 1254 | var pct = total ? Math.round((loaded / total) * 100) : 0; | |
| 1255 | fill.style.width = pct + '%'; | |
| 1256 | meta.textContent = pct + '% — ' + fmtBytes(loaded) + ' of ' + fmtBytes(total); | |
| 1257 | }, | |
| 1258 | beginSaving: function () { | |
| 1259 | ttl.textContent = 'Saving your listing…'; | |
| 1260 | sub.textContent = 'Writing the model record and queueing marketplace pushes.'; | |
| 1261 | fill.style.width = '100%'; | |
| 1262 | fill.classList.add('is-indeterminate'); | |
| 1263 | meta.textContent = 'Processing on the server'; | |
| 1264 | }, | |
| 1265 | finish: function () { | |
| 1266 | ttl.textContent = 'Done — redirecting…'; | |
| 1267 | fill.classList.remove('is-indeterminate'); | |
| 1268 | meta.textContent = 'Opening your models list'; | |
| 1269 | }, | |
| 1270 | fail: function (msg) { | |
| 1271 | ttl.textContent = 'Upload failed'; | |
| 1272 | sub.textContent = msg || 'Something went wrong. Please retry.'; | |
| 1273 | fill.classList.remove('is-indeterminate'); | |
| 1274 | fill.style.background = '#ef4444'; | |
| 1275 | meta.innerHTML = '<button type="button" class="btn btn-secondary btn-sm" id="upProgClose">Close</button>'; | |
| 1276 | document.getElementById('upProgClose').addEventListener('click', function () { | |
| 1277 | ov.remove(); | |
| 1278 | }); | |
| 1279 | } | |
| 1280 | }; | |
| 1281 | } | |
| 1282 | function collectRows(id) { | |
| 1283 | var arr = []; | |
| 1284 | document.querySelectorAll('#' + id + ' .up-row input').forEach(function(i) { | |
| 1285 | var v = (i.value || '').trim(); | |
| 1286 | if (v) arr.push(v); | |
| 1287 | }); | |
| 1288 | return JSON.stringify(arr); | |
| 1289 | } | |
| 1290 | ||
| 1291 | /* ---- Top "Save draft" button shortcut. | |
| 1292 | Fires the form's submit event (NOT form.submit() which bypasses | |
| 1293 | it) so the XHR + progress modal path runs. We do this by | |
| 1294 | injecting a real hidden submit button with _action=draft and | |
| 1295 | requesting submit through it; that becomes the submitter so | |
| 1296 | submitWithProgress() picks up the right action. Only present | |
| 1297 | in create mode -- guard for null in edit mode. ---- */ | |
| 1298 | var saveDraftTop = document.getElementById('upSaveDraftTop'); | |
| 1299 | if (saveDraftTop) { | |
| 1300 | saveDraftTop.addEventListener('click', function () { | |
| 1301 | var trigger = document.createElement('button'); | |
| 1302 | trigger.type = 'submit'; | |
| 1303 | trigger.name = '_action'; | |
| 1304 | trigger.value = 'draft'; | |
| 1305 | trigger.style.display = 'none'; | |
| 1306 | form.appendChild(trigger); | |
| 1307 | if (form.requestSubmit) { | |
| 1308 | form.requestSubmit(trigger); | |
| 1309 | } else { | |
| 1310 | trigger.click(); /* fallback for very old browsers */ | |
| 1311 | } | |
| 1312 | }); | |
| 1313 | } | |
| 1314 | ||
| 1315 | /* ---- Delete model button (edit mode, soft delete). | |
| 1316 | Sets models.status='removed'. Every place that lists models | |
| 1317 | already filters this status out, so the model disappears | |
| 1318 | from the storefront, the Models page, and marketplace pushes. | |
| 1319 | The row stays in the DB -- nothing is destroyed. ---- */ | |
| 1320 | var delBtn = document.getElementById('upDeleteModel'); | |
| 1321 | if (delBtn) { | |
| 1322 | delBtn.addEventListener('mouseenter', function(){ delBtn.style.background = 'rgba(239,68,68,0.22)'; }); | |
| 1323 | delBtn.addEventListener('mouseleave', function(){ delBtn.style.background = 'rgba(239,68,68,0.10)'; }); | |
| 1324 | delBtn.addEventListener('click', function() { | |
| 1325 | var mid = delBtn.getAttribute('data-model-id'); | |
| 1326 | if (!mid) return; | |
| 1327 | appConfirm({ | |
| 1328 | title: 'Delete this model?', | |
| 1329 | message: 'It will disappear from your storefront and every marketplace push immediately. You can restore it later from the Trash tab on the Models page.', | |
| 1330 | confirm_label: 'Move to trash', | |
| 1331 | confirm_style: 'warning' | |
| 1332 | }).then(function(ok) { | |
| 1333 | if (!ok) return; | |
| 1334 | delBtn.disabled = true; | |
| 1335 | delBtn.textContent = 'Deleting...'; | |
| 1336 | var fd = new FormData(); | |
| 1337 | fd.append('field', 'status'); | |
| 1338 | fd.append('value', 'removed'); | |
| 1339 | fd.append('model_id', mid); | |
| 1340 | fetch('/update_model_field.cgi', { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 1341 | .then(function(r){ | |
| 1342 | return r.text().then(function(body){ | |
| 1343 | try { return JSON.parse(body); } | |
| 1344 | catch (e) { | |
| 1345 | var snippet = body ? body.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim().slice(0, 200) : '(empty body)'; | |
| 1346 | throw new Error('server returned ' + r.status + ' ' + r.statusText + ' (' + snippet + ')'); | |
| 1347 | } | |
| 1348 | }); | |
| 1349 | }) | |
| 1350 | .then(function(data){ | |
| 1351 | if (!data || !data.success) throw new Error(data && data.error || 'delete failed'); | |
| 1352 | window.location.href = '/models.cgi?deleted=' + mid; | |
| 1353 | }) | |
| 1354 | .catch(function(err){ | |
| 1355 | delBtn.disabled = false; | |
| 1356 | delBtn.innerHTML = '× Delete model'; | |
| 1357 | appToast({ message: 'Delete failed: ' + err.message, type: 'error' }); | |
| 1358 | }); | |
| 1359 | }); | |
| 1360 | }); | |
| 1361 | } | |
| 1362 | ||
| 1363 | /* ---- Existing image / file remove buttons (edit mode). | |
| 1364 | Each pre-existing item has a small X button with data-rm-img | |
| 1365 | or data-rm-file. Click sends an AJAX POST to remove the row | |
| 1366 | and the on-disk file. The DOM element is removed on success. ---- */ | |
| 1367 | document.addEventListener('click', function(e) { | |
| 1368 | var btn = e.target.closest('[data-rm-img], [data-rm-file]'); | |
| 1369 | if (!btn) return; | |
| 1370 | e.preventDefault(); | |
| 1371 | e.stopPropagation(); | |
| 1372 | var rowId = btn.getAttribute('data-rm-img') || btn.getAttribute('data-rm-file'); | |
| 1373 | var kind = btn.hasAttribute('data-rm-img') ? 'remove_image' : 'remove_file'; | |
| 1374 | if (!rowId) return; | |
| 1375 | appConfirm({ | |
| 1376 | title: 'Remove this ' + (kind === 'remove_image' ? 'image' : 'file') + '?', | |
| 1377 | message: 'It will be removed from this listing and deleted from disk. This cannot be undone.', | |
| 1378 | confirm_label: 'Remove', | |
| 1379 | confirm_style: 'danger' | |
| 1380 | }).then(function(ok) { | |
| 1381 | if (!ok) return; | |
| 1382 | var container = btn.closest('[data-existing-img-id], [data-existing-file-id]'); | |
| 1383 | if (container) container.style.opacity = '0.4'; | |
| 1384 | var fd = new FormData(); | |
| 1385 | fd.append('_ajax', kind); | |
| 1386 | fd.append('row_id', rowId); | |
| 1387 | fetch('/upload_model.cgi', { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 1388 | .then(function(r){ | |
| 1389 | return r.text().then(function(body){ | |
| 1390 | try { return JSON.parse(body); } | |
| 1391 | catch (e) { | |
| 1392 | var snippet = body ? body.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim().slice(0, 200) : '(empty body)'; | |
| 1393 | throw new Error('server returned ' + r.status + ' ' + r.statusText + ' (' + snippet + ')'); | |
| 1394 | } | |
| 1395 | }); | |
| 1396 | }) | |
| 1397 | .then(function(data){ | |
| 1398 | if (!data || !data.success) throw new Error(data && data.error || 'remove failed'); | |
| 1399 | if (container) container.remove(); | |
| 1400 | }) | |
| 1401 | .catch(function(err){ | |
| 1402 | if (container) container.style.opacity = ''; | |
| 1403 | appToast({ message: 'Remove failed: ' + err.message, type: 'error' }); | |
| 1404 | }); | |
| 1405 | }); | |
| 1406 | }); | |
| 1407 | ||
| 1408 | /* ---- Helpers ---- */ | |
| 1409 | function formatBytes(b) { | |
| 1410 | if (b < 1024) return b + ' B'; | |
| 1411 | if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; | |
| 1412 | return (b / 1048576).toFixed(1) + ' MB'; | |
| 1413 | } | |
| 1414 | function escapeHtml(s) { | |
| 1415 | return String(s).replace(/[&<>"']/g, function(c) { | |
| 1416 | return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; | |
| 1417 | }); | |
| 1418 | } | |
| 1419 | })(); | |
| 1420 | </script> |