Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/TEMPLATES/webstls_upload_model.html
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/TEMPLATES/webstls_upload_model.html

added on local at 2026-07-01 13:48:12

Added
+1420
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to da0be42ce86e
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
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">&larr; Models</a> &middot; 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 &mdash; 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&times;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">&times;</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 &middot; PNG &middot; WEBP &middot; 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 &mdash; 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 &mdash; 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 &middot; already attached</div>
387 </div>
388 <button type="button" class="up-file-remove" data-rm-file="$loop1.id" title="Remove from this model">&times;</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 &middot; OBJ &middot; STEP &middot; STP &middot; 3MF &middot; 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 &mdash; 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">&#9654;</span>
430 <input type="url" class="input" placeholder="https://youtube.com/watch?v=&hellip;" value="$loop1.text">
431 <button type="button" class="up-row-x" aria-label="Remove">&times;</button>
432 </div>
433 [/loop]
434 [/if]
435 [if:!$has_videos]
436 <div class="up-row">
437 <span class="icon">&#9654;</span>
438 <input type="url" class="input" placeholder="https://youtube.com/watch?v=&hellip;">
439 <button type="button" class="up-row-x" disabled aria-label="Remove">&times;</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">&#10022;</span>
464 <input type="text" class="input" placeholder="e.g. Pre-supported &mdash; no manual setup needed" maxlength="120" value="$loop1.text">
465 <button type="button" class="up-row-x" aria-label="Remove">&times;</button>
466 </div>
467 [/loop]
468 [/if]
469 [if:!$has_highlights]
470 <div class="up-row">
471 <span class="icon">&#10022;</span>
472 <input type="text" class="input" placeholder="e.g. Pre-supported &mdash; no manual setup needed" maxlength="120">
473 <button type="button" class="up-row-x" disabled aria-label="Remove">&times;</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 &mdash; 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">&#10003;</span>
498 <input type="text" class="input" placeholder="e.g. 12 &times; STL files &mdash; one per part, named and numbered" maxlength="140" value="$loop1.text">
499 <button type="button" class="up-row-x" aria-label="Remove">&times;</button>
500 </div>
501 [/loop]
502 [/if]
503 [if:!$has_included]
504 <div class="up-row">
505 <span class="icon">&#10003;</span>
506 <input type="text" class="input" placeholder="e.g. 12 &times; STL files &mdash; one per part, named and numbered" maxlength="140">
507 <button type="button" class="up-row-x" disabled aria-label="Remove">&times;</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 &mdash; assembly, materials, scale, compatible printers, what it's for&hellip;">$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="">&mdash; Select &mdash;</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 &rarr;</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 &mdash; 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 &mdash; 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&#10;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&hellip;</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&hellip;</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&ndash;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&deg;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&deg;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&ndash;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&ndash;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 &times; 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 &mdash; visible to everyone</label>
768 <label><input type="radio" name="visibility" value="unlisted" $sel_vis_unlisted> Unlisted &mdash; direct link only</label>
769 <label><input type="radio" name="visibility" value="private" $sel_vis_private> Private &mdash; 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 &middot; 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 &mdash; 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 &mdash; 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 &times; 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">&times;</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 &mdash; 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">&times;</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=&hellip;', '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">&times;</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 = '&times; 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 ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c];
1417 });
1418 }
1419})();
1420</script>
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help