added on WebSTLs (webstls.com) at 2026-07-01 22:27:02
| 1 | <!-- ===================================================================== | |
| 2 | Models management page. Cards per model with controls for: | |
| 3 | - Feature (move to top of storefront) | |
| 4 | - Show / Hide on storefront | |
| 5 | - Move up / Move down in sort order | |
| 6 | All write actions POST to /update_listing.cgi. | |
| 7 | ===================================================================== --> | |
| 8 | ||
| 9 | <style> | |
| 10 | .mod-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px; } | |
| 11 | .mod-card { | |
| 12 | position: relative; | |
| 13 | background: var(--col-surface-1); | |
| 14 | border: 1px solid var(--col-border); | |
| 15 | border-radius: 12px; | |
| 16 | overflow: hidden; | |
| 17 | transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; | |
| 18 | display: flex; flex-direction: column; | |
| 19 | } | |
| 20 | .mod-card:hover { border-color: rgba(124,58,237,0.45); transform: translateY(-2px); box-shadow: 0 12px 30px rgba(0,0,0,0.4); } | |
| 21 | .mod-card.is-hidden { opacity: 0.55; } | |
| 22 | .mod-card.is-saving { pointer-events: none; opacity: 0.5; } | |
| 23 | .mod-hero { aspect-ratio: 4/3; background-size: cover; background-position: center; border-bottom: 1px solid var(--col-border); } | |
| 24 | .mod-body { padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; flex: 1; } | |
| 25 | .mod-title { font-size: 15px; font-weight: 700; color: var(--col-text); line-height: 1.3; } | |
| 26 | .mod-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } | |
| 27 | .mod-price { font-size: 14px; font-weight: 700; color: var(--col-accent-bright); font-family: var(--font-mono, monospace); } | |
| 28 | .mod-category { font-size: 11px; color: var(--col-text-3); letter-spacing: 1px; text-transform: uppercase; } | |
| 29 | .mod-actions { padding: 10px 12px; border-top: 1px solid var(--col-border); display: flex; flex-direction: column; gap: 6px; background: var(--col-surface-2); } | |
| 30 | .mod-act-row { display: grid; gap: 6px; } | |
| 31 | .mod-act-row.r-2 { grid-template-columns: 1fr 1fr; } | |
| 32 | .mod-act-row.r-4 { grid-template-columns: repeat(4, 1fr); } | |
| 33 | .mod-act { background: none; border: 1px solid var(--col-border); border-radius: 6px; color: var(--col-text-2); cursor: pointer; padding: 7px 0; font-size: 11px; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; font-family: inherit; transition: all 0.15s; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 4px; } | |
| 34 | .mod-act:hover { border-color: var(--col-accent); color: var(--col-text); background: rgba(124,58,237,0.10); } | |
| 35 | .mod-act:disabled { opacity: 0.35; cursor: not-allowed; } | |
| 36 | .mod-act.is-feature { background: linear-gradient(130deg, #7c3aed, #3b82f6); color: #fff; border-color: transparent; } | |
| 37 | .mod-act.is-feature:hover { filter: brightness(1.1); } | |
| 38 | /* Make Edit Details visually weighted like Publish (the other | |
| 39 | row-1 primary action). Higher background opacity + saturated | |
| 40 | text color so it reads at a glance against the card surface | |
| 41 | instead of blending in. */ | |
| 42 | .mod-act.is-edit { background: rgba(124,58,237,0.28); border-color: rgba(167,139,250,0.70); color: #c4b5fd; } | |
| 43 | .mod-act.is-edit:hover { background: rgba(124,58,237,0.45); border-color: #a78bfa; color: #fff; } | |
| 44 | .mod-act.is-publish { background: rgba(34,197,94,0.14); border-color: rgba(34,197,94,0.4); color: #4ade80; } | |
| 45 | .mod-act.is-publish:hover { background: rgba(34,197,94,0.25); } | |
| 46 | .mod-act.is-unpublish { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.35); color: #fbbf24; } | |
| 47 | .mod-act.is-unpublish:hover { background: rgba(245,158,11,0.22); } | |
| 48 | .mod-act.is-restore { background: rgba(34,197,94,0.14); border-color: rgba(34,197,94,0.45); color: #4ade80; } | |
| 49 | .mod-act.is-restore:hover { background: rgba(34,197,94,0.26); } | |
| 50 | .mod-card.is-trash { border-color: rgba(239,68,68,0.25); } | |
| 51 | .mod-card.is-trash .mod-hero { filter: grayscale(0.6) brightness(0.7); } | |
| 52 | .mod-corner-badge { position: absolute; top: 10px; left: 10px; z-index: 2; } | |
| 53 | .mod-delete-btn { | |
| 54 | position: absolute; top: 10px; right: 10px; z-index: 3; | |
| 55 | width: 28px; height: 28px; border-radius: 50%; | |
| 56 | background: rgba(0,0,0,0.55); | |
| 57 | border: 1px solid rgba(255,255,255,0.12); | |
| 58 | color: #fff; font-size: 14px; line-height: 1; | |
| 59 | cursor: pointer; display: grid; place-items: center; | |
| 60 | opacity: 0; transition: opacity 0.15s, background 0.15s, border-color 0.15s; | |
| 61 | font-family: inherit; | |
| 62 | } | |
| 63 | .mod-card:hover .mod-delete-btn { opacity: 1; } | |
| 64 | .mod-delete-btn:hover { background: #ef4444; border-color: #f87171; } | |
| 65 | .mod-empty { | |
| 66 | text-align: center; padding: 80px 32px; border: 1px dashed var(--col-border-2); | |
| 67 | border-radius: 14px; color: var(--col-text-2); | |
| 68 | } | |
| 69 | .mod-empty h2 { font-size: 22px; margin-bottom: 8px; color: var(--col-text); } | |
| 70 | .mod-empty p { color: var(--col-text-2); margin-bottom: 18px; } | |
| 71 | .mod-import-cta { | |
| 72 | margin-top: 18px; | |
| 73 | padding: 22px 26px; | |
| 74 | border: 1px solid var(--col-border); | |
| 75 | background: linear-gradient(135deg, rgba(124,58,237,0.10), rgba(139,92,246,0.04)); | |
| 76 | border-radius: 14px; | |
| 77 | display: flex; | |
| 78 | align-items: center; | |
| 79 | gap: 18px; | |
| 80 | flex-wrap: wrap; | |
| 81 | } | |
| 82 | .mod-import-cta-icon { | |
| 83 | width: 44px; height: 44px; flex-shrink: 0; | |
| 84 | border-radius: 12px; | |
| 85 | background: rgba(124,58,237,0.18); | |
| 86 | display: grid; place-items: center; | |
| 87 | color: #a78bfa; | |
| 88 | } | |
| 89 | .mod-import-cta-body { flex: 1; min-width: 200px; } | |
| 90 | .mod-import-cta h3 { margin: 0 0 4px; font-size: 15px; color: var(--col-text); } | |
| 91 | .mod-import-cta p { margin: 0; color: var(--col-text-2); font-size: 13px; line-height: 1.5; } | |
| 92 | .mod-import-cta a.btn { flex-shrink: 0; } | |
| 93 | .mod-stats { display: flex; gap: 14px; margin-bottom: 24px; flex-wrap: wrap; } | |
| 94 | .mod-stat { | |
| 95 | background: var(--col-surface-1); | |
| 96 | border: 1px solid var(--col-border); | |
| 97 | border-radius: 10px; | |
| 98 | padding: 14px 18px; | |
| 99 | min-width: 140px; | |
| 100 | cursor: pointer; | |
| 101 | transition: border-color 0.15s, transform 0.15s, background 0.15s; | |
| 102 | text-decoration: none; | |
| 103 | display: block; | |
| 104 | } | |
| 105 | .mod-stat:hover { border-color: rgba(124,58,237,0.55); transform: translateY(-2px); } | |
| 106 | .mod-stat.is-active { | |
| 107 | border-color: var(--col-accent); | |
| 108 | background: linear-gradient(130deg, rgba(124,58,237,0.12), rgba(59,130,246,0.06)); | |
| 109 | box-shadow: 0 6px 18px rgba(124,58,237,0.18); | |
| 110 | } | |
| 111 | .mod-stat-lbl { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); margin-bottom: 4px; font-weight: 700; } | |
| 112 | .mod-stat-val { font-size: 24px; font-weight: 700; color: var(--col-text); } | |
| 113 | .mod-stat-val.green { color: #4ade80; } | |
| 114 | .mod-stat-val.amber { color: #fbbf24; } | |
| 115 | .mod-stat-val.dim { color: var(--col-text-3); } | |
| 116 | .mod-stat-val.red { color: #f87171; } | |
| 117 | ||
| 118 | .mod-filter-bar { | |
| 119 | display: flex; align-items: center; justify-content: space-between; | |
| 120 | margin-bottom: 12px; gap: 12px; | |
| 121 | font-size: 12px; color: var(--col-text-3); | |
| 122 | letter-spacing: 1px; text-transform: uppercase; | |
| 123 | } | |
| 124 | .mod-filter-bar .mod-filter-name { color: var(--col-text-2); font-weight: 700; } | |
| 125 | .mod-filter-bar a { color: var(--col-accent-bright); text-decoration: none; font-weight: 700; } | |
| 126 | </style> | |
| 127 | ||
| 128 | <div class="page-head"> | |
| 129 | <div> | |
| 130 | <span class="page-eyebrow"><span class="dot"></span> Models</span> | |
| 131 | <h1 class="page-title">Your designs</h1> | |
| 132 | <p class="page-subtitle">Manage which models appear on your storefront, the order they show up, and which one is featured. $model_count total · $listed_count on storefront · $hidden_count hidden.</p> | |
| 133 | </div> | |
| 134 | <div class="page-actions"> | |
| 135 | <a href="/upload_model.cgi" class="btn btn-primary" id="modUploadCta" title="Open the upload form to add a new model (digital file, physical print, or both)"> | |
| 136 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><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> | |
| 137 | Upload new model | |
| 138 | </a> | |
| 139 | </div> | |
| 140 | </div> | |
| 141 | ||
| 142 | <div class="mod-stats"> | |
| 143 | <a class="mod-stat[if:$is_filter_all] is-active[/if]" href="/models.cgi" title="All models you own (drafts + published + hidden, excludes trash)"> | |
| 144 | <div class="mod-stat-lbl">All models</div> | |
| 145 | <div class="mod-stat-val">$model_count</div> | |
| 146 | </a> | |
| 147 | <a class="mod-stat[if:$is_filter_pub] is-active[/if]" href="/models.cgi?filter=published" title="Published AND currently visible on your storefront"> | |
| 148 | <div class="mod-stat-lbl">On storefront</div> | |
| 149 | <div class="mod-stat-val green">$listed_count</div> | |
| 150 | </a> | |
| 151 | <a class="mod-stat[if:$is_filter_drafts] is-active[/if]" href="/models.cgi?filter=drafts" title="Saved as draft -- nobody can see these except you"> | |
| 152 | <div class="mod-stat-lbl">Drafts</div> | |
| 153 | <div class="mod-stat-val amber">$drafts_count</div> | |
| 154 | </a> | |
| 155 | <a class="mod-stat[if:$is_filter_hidden] is-active[/if]" href="/models.cgi?filter=hidden" title="Published but you toggled them off your storefront with Hide"> | |
| 156 | <div class="mod-stat-lbl">Hidden</div> | |
| 157 | <div class="mod-stat-val dim">$hidden_count</div> | |
| 158 | </a> | |
| 159 | [if:$has_trash] | |
| 160 | <a class="mod-stat[if:$is_filter_trash] is-active[/if]" href="/models.cgi?filter=trash" title="Deleted models -- restore from here"> | |
| 161 | <div class="mod-stat-lbl">Trash</div> | |
| 162 | <div class="mod-stat-val red">$trash_count</div> | |
| 163 | </a> | |
| 164 | [/if] | |
| 165 | </div> | |
| 166 | ||
| 167 | <div class="mod-filter-bar"> | |
| 168 | <span><span class="mod-filter-name">Showing:</span> $filter_label</span> | |
| 169 | <span style="display:flex;gap:14px;align-items:center"> | |
| 170 | [if:$is_filter_trash][if:$has_models] | |
| 171 | <button type="button" id="modEmptyTrash" style="background:rgba(239,68,68,0.10);border:1px solid rgba(239,68,68,0.35);color:#f87171;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;font-family:inherit;transition:all 0.15s"> | |
| 172 | Empty trash | |
| 173 | </button> | |
| 174 | [/if][/if] | |
| 175 | [if:!$is_filter_all]<a href="/models.cgi">Clear filter ×</a>[/if] | |
| 176 | </span> | |
| 177 | </div> | |
| 178 | ||
| 179 | [if:$has_models] | |
| 180 | ||
| 181 | <div class="mod-grid"> | |
| 182 | [loop:@models] | |
| 183 | <div class="mod-card[if:!$loop1.is_visible] is-hidden[/if][if:$loop1.is_removed] is-trash[/if]" data-mid="$loop1.id" data-title="$loop1.title"> | |
| 184 | <div class="mod-corner-badge">$loop1.status_pill</div> | |
| 185 | [ptag:edit_models] | |
| 186 | [if:!$loop1.is_removed] | |
| 187 | <button type="button" class="mod-delete-btn" data-model-action="delete" title="Delete this model (moves to Trash)">×</button> | |
| 188 | [/if] | |
| 189 | [/ptag:edit_models] | |
| 190 | [ptag:delete_models] | |
| 191 | [if:$loop1.is_removed] | |
| 192 | <button type="button" class="mod-delete-btn" data-model-action="purge" title="Permanently delete this model. Removes it from your trash and you will not be able to restore it.">×</button> | |
| 193 | [/if] | |
| 194 | [/ptag:delete_models] | |
| 195 | <div class="mod-hero" style="background-image:url('$loop1.hero_image');background-position:$loop1.hero_pos"></div> | |
| 196 | <div class="mod-body"> | |
| 197 | <div class="mod-title">$loop1.title</div> | |
| 198 | <div class="mod-meta-row"> | |
| 199 | <span class="mod-price">$loop1.price</span> | |
| 200 | <span class="mod-category">$loop1.category</span> | |
| 201 | </div> | |
| 202 | </div> | |
| 203 | <div class="mod-actions"> | |
| 204 | [if:$loop1.is_removed] | |
| 205 | <div class="mod-act-row r-2"> | |
| 206 | [ptag:edit_models] | |
| 207 | <button type="button" class="mod-act is-restore" data-model-action="restore" title="Restore this model -- moves it back to drafts"> | |
| 208 | ↺ Restore to drafts | |
| 209 | </button> | |
| 210 | [/ptag:edit_models] | |
| 211 | <a class="mod-act is-edit" href="/upload_model.cgi?model_id=$loop1.id" title="Open the editor to inspect what was in this model"> | |
| 212 | View details | |
| 213 | </a> | |
| 214 | </div> | |
| 215 | [/if] | |
| 216 | [if:!$loop1.is_removed] | |
| 217 | <div class="mod-act-row r-2"> | |
| 218 | [ptag:edit_models] | |
| 219 | <a class="mod-act is-edit" href="/upload_model.cgi?model_id=$loop1.id" title="Edit title, description, price, images, files, status, etc."> | |
| 220 | <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5Z"/></svg> | |
| 221 | Edit details | |
| 222 | </a> | |
| 223 | [if:$loop1.is_draft] | |
| 224 | <button type="button" class="mod-act is-publish" data-model-action="status" data-value="published" title="Publish this model so it can appear on your storefront and other marketplaces"> | |
| 225 | ▸ Publish | |
| 226 | </button> | |
| 227 | [/if] | |
| 228 | [if:!$loop1.is_draft] | |
| 229 | <button type="button" class="mod-act is-unpublish" data-model-action="status" data-value="draft" title="Move back to draft. The model stays in your account but is hidden from buyers everywhere."> | |
| 230 | ▮▮ Unpublish | |
| 231 | </button> | |
| 232 | [/if] | |
| 233 | [/ptag:edit_models] | |
| 234 | </div> | |
| 235 | <div class="mod-act-row r-4"> | |
| 236 | <button type="button" class="mod-act is-feature" data-listing-action="feature" title="Make this the spotlight featured product">★ Feature</button> | |
| 237 | <button type="button" class="mod-act" data-listing-action="toggle_visible" title="Show / hide on YOUR storefront (separate from publish status)">[if:$loop1.is_visible]Hide[/if][if:!$loop1.is_visible]Show[/if]</button> | |
| 238 | <button type="button" class="mod-act" data-listing-action="move_up" title="Move up in storefront order">↑ Up</button> | |
| 239 | <button type="button" class="mod-act" data-listing-action="move_down" title="Move down in storefront order">↓ Down</button> | |
| 240 | </div> | |
| 241 | [/if] | |
| 242 | </div> | |
| 243 | </div> | |
| 244 | [/loop] | |
| 245 | </div> | |
| 246 | [/if] | |
| 247 | ||
| 248 | [if:!$has_models] | |
| 249 | [if:$is_filter_trash] | |
| 250 | <div class="mod-empty"> | |
| 251 | <h2>Trash is empty</h2> | |
| 252 | <p>Deleted models would appear here. Nothing to restore right now.</p> | |
| 253 | <a href="/models.cgi" class="btn btn-secondary">Back to all models</a> | |
| 254 | </div> | |
| 255 | [/if] | |
| 256 | [if:$is_filter_drafts] | |
| 257 | <div class="mod-empty"> | |
| 258 | <h2>No drafts</h2> | |
| 259 | <p>Drafts are models you have saved but not yet published. You don't have any right now.</p> | |
| 260 | <a href="/models.cgi" class="btn btn-secondary">Back to all models</a> | |
| 261 | </div> | |
| 262 | [/if] | |
| 263 | [if:$is_filter_pub] | |
| 264 | <div class="mod-empty"> | |
| 265 | <h2>Nothing on your storefront</h2> | |
| 266 | <p>Published models that are visible on your storefront would appear here.</p> | |
| 267 | <a href="/models.cgi" class="btn btn-secondary">Back to all models</a> | |
| 268 | </div> | |
| 269 | [/if] | |
| 270 | [if:$is_filter_hidden] | |
| 271 | <div class="mod-empty"> | |
| 272 | <h2>No hidden models</h2> | |
| 273 | <p>This shows models you have published but toggled off your storefront with Hide. Nothing matches.</p> | |
| 274 | <a href="/models.cgi" class="btn btn-secondary">Back to all models</a> | |
| 275 | </div> | |
| 276 | [/if] | |
| 277 | [if:$is_filter_all] | |
| 278 | <div class="mod-empty"> | |
| 279 | <h2>No models yet</h2> | |
| 280 | <p>Upload your first design and it will appear here, automatically listed on your storefront.</p> | |
| 281 | <a href="/upload_model.cgi" class="btn btn-primary">Upload your first model</a> | |
| 282 | </div> | |
| 283 | <div class="mod-import-cta"> | |
| 284 | <div class="mod-import-cta-icon"> | |
| 285 | <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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> | |
| 286 | </div> | |
| 287 | <div class="mod-import-cta-body"> | |
| 288 | <h3>Already selling on another marketplace?</h3> | |
| 289 | <p>Pull your existing listings from Cults3D, Printables, Patreon, MakerWorld and more into WebSTLs in one click — titles, descriptions, images, and pricing come along for the ride.</p> | |
| 290 | </div> | |
| 291 | <a href="/marketplaces.cgi" class="btn btn-secondary">Import from a marketplace →</a> | |
| 292 | </div> | |
| 293 | [/if] | |
| 294 | [/if] | |
| 295 | ||
| 296 | ||
| 297 | <script> | |
| 298 | (function() { | |
| 299 | /* ---- Empty trash (bulk purge). Visible only on the Trash filter. ---- */ | |
| 300 | var emptyBtn = document.getElementById('modEmptyTrash'); | |
| 301 | if (emptyBtn) { | |
| 302 | emptyBtn.addEventListener('mouseenter', function(){ emptyBtn.style.background = 'rgba(239,68,68,0.22)'; }); | |
| 303 | emptyBtn.addEventListener('mouseleave', function(){ emptyBtn.style.background = 'rgba(239,68,68,0.10)'; }); | |
| 304 | emptyBtn.addEventListener('click', function() { | |
| 305 | appConfirm({ | |
| 306 | title: 'Empty trash?', | |
| 307 | message: 'Every model currently in your trash will be permanently deleted from your account. You will not be able to restore them after confirming.', | |
| 308 | confirm_label: 'Empty trash', | |
| 309 | confirm_style: 'danger' | |
| 310 | }).then(function(ok) { | |
| 311 | if (!ok) return; | |
| 312 | emptyBtn.disabled = true; | |
| 313 | emptyBtn.textContent = 'Emptying...'; | |
| 314 | var fd = new FormData(); | |
| 315 | fd.append('all', '1'); | |
| 316 | fetch('/purge_models.cgi', { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 317 | .then(function(r){ | |
| 318 | return r.text().then(function(body){ | |
| 319 | try { return JSON.parse(body); } | |
| 320 | catch (e) { | |
| 321 | var snippet = body ? body.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim().slice(0, 200) : '(empty body)'; | |
| 322 | throw new Error('server returned ' + r.status + ' ' + r.statusText + ' (' + snippet + ')'); | |
| 323 | } | |
| 324 | }); | |
| 325 | }) | |
| 326 | .then(function(data){ | |
| 327 | if (!data || !data.success) throw new Error(data && data.error || 'empty failed'); | |
| 328 | window.location.href = '/models.cgi'; | |
| 329 | }) | |
| 330 | .catch(function(err){ | |
| 331 | emptyBtn.disabled = false; | |
| 332 | emptyBtn.textContent = 'Empty trash'; | |
| 333 | appToast({ message: 'Empty trash failed: ' + err.message, type: 'error' }); | |
| 334 | }); | |
| 335 | }); | |
| 336 | }); | |
| 337 | } | |
| 338 | ||
| 339 | function postJson(url, fd, card) { | |
| 340 | card.classList.add('is-saving'); | |
| 341 | return fetch(url, { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 342 | .then(function(r){ | |
| 343 | return r.text().then(function(body){ | |
| 344 | try { return JSON.parse(body); } | |
| 345 | catch (e) { | |
| 346 | var snippet = body ? body.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim().slice(0, 200) : '(empty body)'; | |
| 347 | throw new Error('server returned ' + r.status + ' ' + r.statusText + ' (' + snippet + ')'); | |
| 348 | } | |
| 349 | }); | |
| 350 | }) | |
| 351 | .then(function(data){ | |
| 352 | if (!data || !data.success) throw new Error(data && data.error || 'action failed'); | |
| 353 | return data; | |
| 354 | }); | |
| 355 | } | |
| 356 | ||
| 357 | /* All listing actions (feature, hide/show, reorder) -> update_listing.cgi */ | |
| 358 | document.addEventListener('click', function(e) { | |
| 359 | var btn = e.target.closest('[data-listing-action]'); | |
| 360 | if (!btn) return; | |
| 361 | var card = btn.closest('.mod-card'); | |
| 362 | if (!card) return; | |
| 363 | var mid = card.getAttribute('data-mid'); | |
| 364 | var action = btn.getAttribute('data-listing-action'); | |
| 365 | if (!mid || !action) return; | |
| 366 | ||
| 367 | var fd = new FormData(); | |
| 368 | fd.append('action', action); | |
| 369 | fd.append('model_id', mid); | |
| 370 | ||
| 371 | postJson('/update_listing.cgi', fd, card) | |
| 372 | .then(function(){ | |
| 373 | if (action === 'feature' || action === 'move_up' || action === 'move_down') { | |
| 374 | window.location.reload(); | |
| 375 | return; | |
| 376 | } | |
| 377 | if (action === 'toggle_visible') { | |
| 378 | var visBtn = card.querySelector('[data-listing-action="toggle_visible"]'); | |
| 379 | var nowHidden = card.classList.toggle('is-hidden'); | |
| 380 | if (visBtn) visBtn.textContent = nowHidden ? 'Show' : 'Hide'; | |
| 381 | card.classList.remove('is-saving'); | |
| 382 | return; | |
| 383 | } | |
| 384 | card.classList.remove('is-saving'); | |
| 385 | }) | |
| 386 | .catch(function(err){ | |
| 387 | card.classList.remove('is-saving'); | |
| 388 | appToast({ message: 'Action failed: ' + err.message, type: 'error' }); | |
| 389 | }); | |
| 390 | }); | |
| 391 | ||
| 392 | /* Model-level actions (status flip, delete) -> update_model_field.cgi. | |
| 393 | "delete" is special: it confirms first, then maps to status=removed | |
| 394 | (soft delete -- the row stays in the DB, every query filters | |
| 395 | status='removed' out so the model disappears from storefront, | |
| 396 | models page, marketplaces, etc.). */ | |
| 397 | document.addEventListener('click', function(e) { | |
| 398 | var btn = e.target.closest('[data-model-action]'); | |
| 399 | if (!btn) return; | |
| 400 | var card = btn.closest('.mod-card'); | |
| 401 | if (!card) return; | |
| 402 | var mid = card.getAttribute('data-mid'); | |
| 403 | var action = btn.getAttribute('data-model-action'); | |
| 404 | var value = btn.getAttribute('data-value'); | |
| 405 | if (!mid || !action) return; | |
| 406 | ||
| 407 | var field, endpoint = '/update_model_field.cgi'; | |
| 408 | if (action === 'delete') { | |
| 409 | var title = card.getAttribute('data-title') || 'this model'; | |
| 410 | appConfirm({ | |
| 411 | title: 'Delete "' + title + '"?', | |
| 412 | message: 'It will disappear from your storefront and every marketplace push immediately. You can restore it later from the Trash tab on this page.', | |
| 413 | confirm_label: 'Move to trash', | |
| 414 | confirm_style: 'warning' | |
| 415 | }).then(function(ok) { | |
| 416 | if (!ok) return; | |
| 417 | var fd = new FormData(); | |
| 418 | fd.append('field', 'status'); | |
| 419 | fd.append('value', 'removed'); | |
| 420 | fd.append('model_id', mid); | |
| 421 | postJson(endpoint, fd, card) | |
| 422 | .then(function(){ window.location.reload(); }) | |
| 423 | .catch(function(err){ | |
| 424 | card.classList.remove('is-saving'); | |
| 425 | appToast({ message: 'Action failed: ' + err.message, type: 'error' }); | |
| 426 | }); | |
| 427 | }); | |
| 428 | return; | |
| 429 | } else if (action === 'restore') { | |
| 430 | /* Non-destructive -- no confirm needed. */ | |
| 431 | field = 'status'; | |
| 432 | value = 'draft'; | |
| 433 | } else if (action === 'purge') { | |
| 434 | /* Permanent (from the user's perspective). Big confirm required. */ | |
| 435 | var title2 = card.getAttribute('data-title') || 'this model'; | |
| 436 | appConfirm({ | |
| 437 | title: 'Permanently delete "' + title2 + '"?', | |
| 438 | message: 'This removes it from your trash for good. You will not be able to restore it from the page after confirming.', | |
| 439 | confirm_label: 'Delete forever', | |
| 440 | confirm_style: 'danger' | |
| 441 | }).then(function(ok) { | |
| 442 | if (!ok) return; | |
| 443 | var fdPurge = new FormData(); | |
| 444 | fdPurge.append('model_id', mid); | |
| 445 | postJson('/purge_models.cgi', fdPurge, card) | |
| 446 | .then(function(){ | |
| 447 | card.style.transition = 'opacity 0.25s, transform 0.25s'; | |
| 448 | card.style.opacity = '0'; | |
| 449 | card.style.transform = 'scale(0.95)'; | |
| 450 | setTimeout(function(){ window.location.reload(); }, 260); | |
| 451 | }) | |
| 452 | .catch(function(err){ | |
| 453 | card.classList.remove('is-saving'); | |
| 454 | appToast({ message: 'Permanent delete failed: ' + err.message, type: 'error' }); | |
| 455 | }); | |
| 456 | }); | |
| 457 | return; | |
| 458 | } else { | |
| 459 | field = action; | |
| 460 | } | |
| 461 | ||
| 462 | var fd = new FormData(); | |
| 463 | fd.append('field', field); | |
| 464 | fd.append('value', value || ''); | |
| 465 | fd.append('model_id', mid); | |
| 466 | ||
| 467 | postJson(endpoint, fd, card) | |
| 468 | .then(function(){ | |
| 469 | if (action === 'delete' || action === 'restore') { | |
| 470 | /* Fade the card out -- on delete it moves to trash, on | |
| 471 | restore it moves back to drafts. Either way the current | |
| 472 | view changes, so a reload is the easiest correct state. */ | |
| 473 | card.style.transition = 'opacity 0.25s, transform 0.25s'; | |
| 474 | card.style.opacity = '0'; | |
| 475 | card.style.transform = 'scale(0.95)'; | |
| 476 | setTimeout(function(){ window.location.reload(); }, 260); | |
| 477 | return; | |
| 478 | } | |
| 479 | window.location.reload(); | |
| 480 | }) | |
| 481 | .catch(function(err){ | |
| 482 | card.classList.remove('is-saving'); | |
| 483 | appToast({ message: 'Action failed: ' + err.message, type: 'error' }); | |
| 484 | }); | |
| 485 | }); | |
| 486 | })(); | |
| 487 | </script> |