Skip to main content

max / makenotwork

23.6 KB · 537 lines History Blame Raw
1 {%- import "partials/_ui.html" as ui -%}
2 <div class="tab-docs"><a href="/docs/items">Docs: Items &rarr;</a></div>
3
4 <div class="content-header">
5 <h2 class="subsection-title">Items</h2>
6 <a href="/dashboard/project/{{ project_slug }}/new-item" class="btn-primary">New Item</a>
7 </div>
8
9 {% if items.is_empty() %}
10 <div class="empty-state empty-state--lg">
11 <p class="mb-4">No items in this project yet. Add your first item to start publishing.</p>
12 <a href="/dashboard/project/{{ project_slug }}/new-item" class="btn-primary">Add First Item</a>
13 <p class="form-hint mt-5">After creating an item, set its pricing and publish it to make it available to fans.</p>
14 </div>
15 {% else %}
16 <div class="content-filters">
17 <input type="text" id="content-search" placeholder="Search items..." oninput="filterContentTable()"
18 class="content-filter-search">
19 <select id="content-filter-status" onchange="filterContentTable()" class="content-filter-select">
20 <option value="">All statuses</option>
21 <option value="Active">Active</option>
22 <option value="Draft">Draft</option>
23 </select>
24 <select id="content-filter-type" onchange="filterContentTable()" class="content-filter-select">
25 <option value="">All types</option>
26 {% for item in items %}<option value="{{ item.item_type }}" class="content-type-opt">{{ item.item_type }}</option>{% endfor %}
27 </select>
28 </div>
29 <div id="bulk-action-bar" class="bulk-action-bar" data-active="false">
30 <span id="bulk-count" class="bulk-count">0 selected</span>
31 <button class="btn-secondary small" onclick="bulkAction('publish')" disabled>Publish</button>
32 <button class="btn-secondary small" onclick="bulkAction('unpublish')" disabled>Unpublish</button>
33 <button class="btn-secondary small" onclick="showBulkPrice()" disabled id="bulk-price-btn">Set Price</button>
34 <button class="btn-secondary small" onclick="showBulkTag()" disabled id="bulk-tag-btn">Add Tag</button>
35 <button class="btn-secondary small danger-text" onclick="bulkAction('delete')" disabled>Delete</button>
36 <button class="btn-secondary small bulk-deselect" onclick="toggleSelectAll(false)" disabled>Deselect</button>
37 </div>
38 <div id="bulk-price-form" class="bulk-form hidden">
39 <div class="bulk-form-row">
40 <div>
41 <label class="bulk-form-label">New price ($)</label>
42 <input type="number" id="bulk-price-input" min="0" step="0.01" placeholder="0.00" class="bulk-form-input input--xs w-120">
43 </div>
44 <button class="btn-primary small" onclick="submitBulkPrice()">Apply</button>
45 <button class="btn-secondary small" onclick="document.getElementById('bulk-price-form').classList.add('hidden')">Cancel</button>
46 </div>
47 <p class="form-hint mt-2">Enter 0 to make items free.</p>
48 </div>
49 <div id="bulk-tag-form" class="bulk-form hidden">
50 <div class="bulk-form-row">
51 <div>
52 <label class="bulk-form-label">Tag slug</label>
53 <input type="text" id="bulk-tag-input" placeholder="e.g. audio.genre.ambient" class="bulk-form-input input--xs w-260">
54 </div>
55 <button class="btn-primary small" onclick="submitBulkTag()">Apply</button>
56 <button class="btn-secondary small" onclick="document.getElementById('bulk-tag-form').classList.add('hidden')">Cancel</button>
57 </div>
58 <p class="form-hint mt-2">Use dot-notation: type.category.value</p>
59 </div>
60 <table class="data-table">
61 <thead>
62 <tr>
63 <th class="col-3"><input type="checkbox" id="select-all" onchange="toggleSelectAll(this.checked)"></th>
64 <th class="col-5">#</th>
65 <th class="col-32 sortable-th" onclick="sortContentTable(2, 'text')" title="Sort by title">Item <span class="sort-arrow" data-col="2"></span></th>
66 <th class="col-12 sortable-th" onclick="sortContentTable(3, 'text')" title="Sort by type">Type <span class="sort-arrow" data-col="3"></span></th>
67 <th class="col-10 sortable-th" onclick="sortContentTable(4, 'money')" title="Sort by price">Price <span class="sort-arrow" data-col="4"></span></th>
68 <th class="col-10 sortable-th" onclick="sortContentTable(5, 'num')" title="Sort by sales">Sales <span class="sort-arrow" data-col="5"></span></th>
69 <th class="col-10 sortable-th" onclick="sortContentTable(6, 'money')" title="Sort by revenue">Revenue <span class="sort-arrow" data-col="6"></span></th>
70 <th class="col-8 sortable-th" onclick="sortContentTable(7, 'text')" title="Sort by status">Status <span class="sort-arrow" data-col="7"></span></th>
71 <th class="col-10">Actions</th>
72 </tr>
73 </thead>
74 <tbody>
75 {% for item in items %}
76 <tr>
77 <td><input type="checkbox" class="bulk-check" value="{{ item.id }}" onchange="updateBulkUI()"></td>
78 <td class="nowrap">
79 {{ item.position }}
80 {% if !loop.first %}
81 <button class="btn-secondary order-arrow-btn"
82 hx-put="/api/items/{{ item.id }}/move" hx-vals='{"direction":"up"}'
83 hx-swap="none"
84 hx-on::after-request="if(event.detail.successful) htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content')">&#9650;</button>
85 {% endif %}
86 {% if !loop.last %}
87 <button class="btn-secondary order-arrow-btn"
88 hx-put="/api/items/{{ item.id }}/move" hx-vals='{"direction":"down"}'
89 hx-swap="none"
90 hx-on::after-request="if(event.detail.successful) htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content')">&#9660;</button>
91 {% endif %}
92 </td>
93 <td>
94 <span id="item-title-{{ item.id }}">
95 {% if !item.children.is_empty() %}
96 <button class="bundle-toggle" onclick="toggleBundleChildren('{{ item.id }}')" title="Toggle bundle contents">&#9654;</button>
97 {% endif %}
98 <a href="/dashboard/item/{{ item.id }}" class="fw-bold">{{ item.title }}</a>
99 {% if item.is_unlisted %}<span class="badge badge--inline-tiny">Unlisted</span>{% endif %}
100 </span>
101 </td>
102 <td>{{ item.item_type }}{% if !item.children.is_empty() %} <span class="dimmed text-xs">({{ item.children.len() }})</span>{% endif %}</td>
103 <td>{{ item.price }}</td>
104 <td>{{ item.sales }}</td>
105 <td>{{ item.revenue }}</td>
106 <td><span class="badge {{ item.status|lowercase }}">{{ item.status }}</span></td>
107 <td>
108 <div class="item-actions">
109 {% if item.status == "Draft" %}
110 <a href="/dashboard/project/{{ project_slug }}/new-item/{{ item.id }}/step/details" class="btn-primary small">Continue</a>
111 <button class="btn-secondary small" onclick="quickPublish('{{ item.id }}')">Publish</button>
112 {% else %}
113 <a href="/i/{{ item.id }}" class="btn-secondary small">View</a>
114 {% endif %}
115 <a href="/dashboard/item/{{ item.id }}" class="btn-secondary small">Edit</a>
116 <button class="btn-secondary small"
117 onclick="inlineRename('{{ item.id }}')">Rename</button>
118 </div>
119 </td>
120 </tr>
121 {% for child in item.children %}
122 <tr class="bundle-child bundle-child-{{ item.id }} hidden">
123 <td><input type="checkbox" class="bulk-check" value="{{ child.id }}" onchange="updateBulkUI()"></td>
124 <td></td>
125 <td class="bundle-child-cell">
126 <span id="item-title-{{ child.id }}" class="bundle-child-title">
127 <span class="bundle-child-arrow">&#8627;</span>
128 <a href="/dashboard/item/{{ child.id }}">{{ child.title }}</a>
129 {% if child.is_unlisted %}<span class="badge badge--inline-tiny">Unlisted</span>{% endif %}
130 </span>
131 </td>
132 <td class="muted">{{ child.item_type }}</td>
133 <td class="muted">{{ child.price }}</td>
134 <td class="muted">{{ child.sales }}</td>
135 <td class="muted">{{ child.revenue }}</td>
136 <td><span class="badge {{ child.status|lowercase }}">{{ child.status }}</span></td>
137 <td>
138 <div class="item-actions">
139 <a href="/dashboard/item/{{ child.id }}" class="btn-secondary small">Edit</a>
140 </div>
141 </td>
142 </tr>
143 {% endfor %}
144 {% endfor %}
145 </tbody>
146 </table>
147 {% endif %}
148
149 {% if !deleted_items.is_empty() %}
150 <details class="deleted-items-details">
151 <summary class="deleted-items-summary">Recently Deleted ({{ deleted_items.len() }})</summary>
152 <p class="form-hint mt-2">Deleted items are permanently removed after 7 days.</p>
153 <table class="data-table mt-3">
154 <thead>
155 <tr>
156 <th>Title</th>
157 <th>Deleted</th>
158 <th></th>
159 </tr>
160 </thead>
161 <tbody>
162 {% for item in deleted_items %}
163 <tr id="deleted-{{ item.id }}">
164 <td class="dimmed">{{ item.title }}</td>
165 <td class="dimmed">{{ item.deleted_at }}</td>
166 <td>
167 <button class="btn-secondary small"
168 hx-post="/api/items/{{ item.id }}/restore"
169 hx-swap="none"
170 hx-on::after-request="if(event.detail.successful) htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content')">Restore</button>
171 </td>
172 </tr>
173 {% endfor %}
174 </tbody>
175 </table>
176 </details>
177 {% endif %}
178
179 <script>
180 function toggleSelectAll(checked) {
181 var boxes = document.querySelectorAll('.bulk-check');
182 for (var i = 0; i < boxes.length; i++) boxes[i].checked = checked;
183 var selectAll = document.getElementById('select-all');
184 if (selectAll) selectAll.checked = checked;
185 updateBulkUI();
186 }
187
188 function updateBulkUI() {
189 var checked = document.querySelectorAll('.bulk-check:checked');
190 var bar = document.getElementById('bulk-action-bar');
191 var countEl = document.getElementById('bulk-count');
192 var selectAll = document.getElementById('select-all');
193 var allBoxes = document.querySelectorAll('.bulk-check');
194 if (!bar) return;
195 var hasSelection = checked.length > 0;
196 bar.classList.toggle('bulk-action-bar--active', hasSelection);
197 bar.dataset.active = hasSelection ? 'true' : 'false';
198 countEl.textContent = checked.length + ' selected';
199 var buttons = bar.querySelectorAll('button');
200 for (var i = 0; i < buttons.length; i++) buttons[i].disabled = !hasSelection;
201 if (selectAll) selectAll.checked = allBoxes.length > 0 && checked.length === allBoxes.length;
202 if (!hasSelection) {
203 document.getElementById('bulk-price-form').classList.add('hidden');
204 document.getElementById('bulk-tag-form').classList.add('hidden');
205 }
206 }
207
208 function bulkAction(action) {
209 var checked = document.querySelectorAll('.bulk-check:checked');
210 if (checked.length === 0) return;
211
212 var titles = [];
213 for (var i = 0; i < checked.length; i++) {
214 var row = checked[i].closest('tr');
215 var link = row ? row.querySelector('td:nth-child(3) a') : null;
216 if (link) titles.push(link.textContent.trim());
217 }
218
219 if (action === 'delete') {
220 var msg = 'Delete ' + checked.length + ' item(s)? This cannot be undone.\n\n' + titles.join('\n');
221 if (!confirm(msg)) return;
222 }
223
224 var count = checked.length;
225 var params = new URLSearchParams();
226 for (var i = 0; i < checked.length; i++) {
227 params.append('item_ids', checked[i].value);
228 }
229
230 fetch('/api/items/bulk/' + action, {
231 method: 'POST',
232 headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
233 body: params.toString()
234 })
235 .then(function(r) {
236 if (!r.ok) return r.text().then(function(t) {
237 var msg = 'Bulk ' + action + ' failed (' + r.status + ')';
238 try { var parsed = JSON.parse(t); if (parsed.error) msg = parsed.error; } catch (_) {}
239 throw new Error(msg);
240 });
241 var label = action === 'delete' ? 'deleted' : action === 'publish' ? 'published' : 'unpublished';
242 showToast(count + ' item(s) ' + label + '.', 'success');
243 htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content');
244 })
245 .catch(function(err) {
246 showToast(err.message || 'Bulk operation failed');
247 });
248 }
249
250 function quickPublish(itemId) {
251 var params = new URLSearchParams();
252 params.append('item_ids', itemId);
253 fetch('/api/items/bulk/publish', {
254 method: 'POST',
255 headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
256 body: params.toString()
257 }).then(function(r) {
258 if (!r.ok) return r.text().then(function(t) {
259 var msg = 'Publish failed';
260 try { var parsed = JSON.parse(t); if (parsed.error) msg = parsed.error; } catch (_) {}
261 throw new Error(msg);
262 });
263 showToast('Item published.', 'success');
264 htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content');
265 }).catch(function(err) {
266 showToast(err.message || 'Publish failed');
267 });
268 }
269
270 function showBulkPrice() {
271 document.getElementById('bulk-price-form').classList.remove('hidden');
272 document.getElementById('bulk-tag-form').classList.add('hidden');
273 document.getElementById('bulk-price-input').focus();
274 }
275
276 function showBulkTag() {
277 document.getElementById('bulk-tag-form').classList.remove('hidden');
278 document.getElementById('bulk-price-form').classList.add('hidden');
279 document.getElementById('bulk-tag-input').focus();
280 }
281
282 function submitBulkPrice() {
283 var checked = document.querySelectorAll('.bulk-check:checked');
284 if (checked.length === 0) return;
285 var price = document.getElementById('bulk-price-input').value;
286 if (price === '') { showToast('Enter a price'); return; }
287
288 var params = new URLSearchParams();
289 for (var i = 0; i < checked.length; i++) params.append('item_ids', checked[i].value);
290 params.append('price_dollars', price);
291
292 fetch('/api/items/bulk/price', {
293 method: 'POST',
294 headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
295 body: params.toString()
296 })
297 .then(function(r) {
298 if (!r.ok) return r.text().then(function(t) {
299 var msg = 'Could not update prices. Try again, or refresh the page.';
300 try { var p = JSON.parse(t); if (p.error) msg = p.error; } catch (_) {}
301 throw new Error(msg);
302 });
303 document.getElementById('bulk-price-form').classList.add('hidden');
304 htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content');
305 })
306 .catch(function(err) { showToast(err.message || 'Could not update prices. Try again, or refresh the page.'); });
307 }
308
309 function submitBulkTag() {
310 var checked = document.querySelectorAll('.bulk-check:checked');
311 if (checked.length === 0) return;
312 var tag = document.getElementById('bulk-tag-input').value.trim();
313 if (!tag) { showToast('Enter a tag.'); return; }
314
315 var params = new URLSearchParams();
316 for (var i = 0; i < checked.length; i++) params.append('item_ids', checked[i].value);
317 params.append('tag_slug', tag);
318
319 fetch('/api/items/bulk/tag', {
320 method: 'POST',
321 headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
322 body: params.toString()
323 })
324 .then(function(r) {
325 if (!r.ok) return r.text().then(function(t) {
326 var msg = 'Could not add the tag. Try again, or refresh the page.';
327 try { var p = JSON.parse(t); if (p.error) msg = p.error; } catch (_) {}
328 throw new Error(msg);
329 });
330 document.getElementById('bulk-tag-form').classList.add('hidden');
331 document.getElementById('bulk-tag-input').value = '';
332 htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content');
333 })
334 .catch(function(err) { showToast(err.message || 'Could not add the tag. Try again, or refresh the page.'); });
335 }
336
337 function toggleBundleChildren(bundleId) {
338 var rows = document.querySelectorAll('.bundle-child-' + bundleId);
339 var btn = document.querySelector('tr:has(.bulk-check[value="' + bundleId + '"]) .bundle-toggle');
340 var visible = rows.length > 0 && !rows[0].classList.contains('hidden');
341 for (var i = 0; i < rows.length; i++) {
342 rows[i].classList.toggle('hidden', visible);
343 }
344 if (btn) btn.innerHTML = visible ? '&#9654;' : '&#9660;';
345 }
346
347 function filterContentTable() {
348 var query = (document.getElementById('content-search').value || '').toLowerCase();
349 var status = document.getElementById('content-filter-status').value;
350 var type = document.getElementById('content-filter-type').value;
351 var rows = document.querySelectorAll('.data-table tbody > tr:not(.bundle-child)');
352 for (var i = 0; i < rows.length; i++) {
353 var row = rows[i];
354 var title = (row.querySelector('td:nth-child(3)') || {}).textContent || '';
355 var rowType = (row.querySelector('td:nth-child(4)') || {}).textContent || '';
356 var rowStatus = row.querySelector('.badge') ? row.querySelector('.badge').textContent.trim() : '';
357 var matchQuery = !query || title.toLowerCase().indexOf(query) !== -1;
358 var matchStatus = !status || rowStatus === status;
359 var matchType = !type || rowType.trim().indexOf(type) !== -1;
360 var visible = matchQuery && matchStatus && matchType;
361 row.classList.toggle('hidden', !visible);
362 var itemCheckbox = row.querySelector('.bulk-check');
363 if (itemCheckbox && !visible) {
364 var children = document.querySelectorAll('.bundle-child-' + itemCheckbox.value);
365 for (var j = 0; j < children.length; j++) {
366 children[j].classList.add('hidden');
367 }
368 }
369 }
370 }
371
372 (function() {
373 var seen = {};
374 var opts = document.querySelectorAll('.content-type-opt');
375 for (var i = 0; i < opts.length; i++) {
376 if (seen[opts[i].value]) { opts[i].remove(); } else { seen[opts[i].value] = true; }
377 }
378 })();
379
380 var contentSortState = { col: -1, asc: true };
381
382 function sortContentTable(colIndex, sortType) {
383 var table = document.querySelector('.data-table');
384 if (!table) return;
385 var tbody = table.querySelector('tbody');
386 var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr:not(.bundle-child)'));
387
388 if (contentSortState.col === colIndex) {
389 contentSortState.asc = !contentSortState.asc;
390 } else {
391 contentSortState.col = colIndex;
392 contentSortState.asc = true;
393 }
394
395 function getValue(row) {
396 var cell = row.cells[colIndex];
397 if (!cell) return '';
398 var text = cell.textContent.trim();
399 if (sortType === 'num') {
400 return parseInt(text.replace(/[^0-9-]/g, ''), 10) || 0;
401 }
402 if (sortType === 'money') {
403 if (text.toLowerCase() === 'free') return 0;
404 return parseFloat(text.replace(/[^0-9.-]/g, '')) || 0;
405 }
406 return text.toLowerCase();
407 }
408
409 rows.sort(function(a, b) {
410 var va = getValue(a);
411 var vb = getValue(b);
412 var cmp = 0;
413 if (typeof va === 'number') {
414 cmp = va - vb;
415 } else {
416 cmp = va < vb ? -1 : va > vb ? 1 : 0;
417 }
418 return contentSortState.asc ? cmp : -cmp;
419 });
420
421 for (var i = 0; i < rows.length; i++) {
422 tbody.appendChild(rows[i]);
423 var checkbox = rows[i].querySelector('.bulk-check');
424 if (checkbox) {
425 var children = Array.prototype.slice.call(tbody.querySelectorAll('.bundle-child-' + checkbox.value));
426 for (var j = 0; j < children.length; j++) {
427 tbody.appendChild(children[j]);
428 }
429 }
430 }
431
432 var arrows = document.querySelectorAll('.sort-arrow');
433 for (var i = 0; i < arrows.length; i++) {
434 var col = parseInt(arrows[i].dataset.col, 10);
435 if (col === colIndex) {
436 arrows[i].textContent = contentSortState.asc ? '' : '';
437 arrows[i].classList.add('sort-arrow--active');
438 } else {
439 arrows[i].textContent = '';
440 arrows[i].classList.remove('sort-arrow--active');
441 }
442 }
443 }
444
445 function inlineRename(itemId) {
446 var container = document.getElementById('item-title-' + itemId);
447 var link = container.querySelector('a');
448 var currentTitle = link.textContent;
449 var href = link.getAttribute('href');
450
451 var input = document.createElement('input');
452 input.type = 'text';
453 input.value = currentTitle;
454 input.className = 'inline-rename-input';
455
456 function restore(newTitle) {
457 container.innerHTML = '';
458 var a = document.createElement('a');
459 a.href = href;
460 a.className = 'fw-bold';
461 a.textContent = newTitle;
462 container.appendChild(a);
463 }
464
465 function save() {
466 var newTitle = input.value.trim();
467 if (!newTitle || newTitle === currentTitle) { restore(currentTitle); return; }
468 var form = new FormData();
469 form.append('title', newTitle);
470 fetch('/api/items/' + itemId, { method: 'PUT', headers: csrfHeaders(), body: form })
471 .then(function(r) {
472 if (!r.ok) throw new Error('Failed');
473 return r.json();
474 })
475 .then(function(data) { restore(data.title); })
476 .catch(function() { restore(currentTitle); });
477 }
478
479 input.addEventListener('keydown', function(e) {
480 if (e.key === 'Enter') { e.preventDefault(); save(); }
481 if (e.key === 'Escape') { restore(currentTitle); }
482 });
483 input.addEventListener('blur', save);
484
485 container.innerHTML = '';
486 container.appendChild(input);
487 input.focus();
488 input.select();
489 }
490 </script>
491
492 <hr class="section-divider">
493
494 <div class="content-header">
495 <h2 class="subsection-title">Blog Posts</h2>
496 <a href="/dashboard/project/{{ project_slug }}/blog/new">
497 <button class="btn-primary">New Post</button>
498 </a>
499 </div>
500
501 {% if posts.is_empty() %}
502 {% call ui::empty_state_compact("No blog posts yet. Share updates, release notes, or stories with your audience.") %}
503 {% else %}
504 <table class="data-table">
505 <thead>
506 <tr>
507 <th class="col-45">Title</th>
508 <th class="col-15">Status</th>
509 <th class="col-20">Published</th>
510 <th class="col-20">Actions</th>
511 </tr>
512 </thead>
513 <tbody>
514 {% for post in posts %}
515 <tr id="post-row-{{ post.id }}">
516 <td>
517 <a href="/p/{{ project_slug }}/blog/{{ post.slug }}" class="fw-bold">{{ post.title }}</a>
518 </td>
519 <td><span class="badge {{ post.status|lowercase }}">{{ post.status }}</span></td>
520 <td>{{ post.published_at }}</td>
521 <td>
522 <div class="item-actions">
523 <a href="/p/{{ project_slug }}/blog/{{ post.slug }}" class="btn-secondary small">View</a>
524 <a href="/dashboard/project/{{ project_slug }}/blog/new?post={{ post.id }}" class="btn-secondary small">Edit</a>
525 <button class="btn-secondary small danger-text"
526 hx-delete="/api/blog/{{ post.id }}"
527 hx-target="#post-row-{{ post.id }}"
528 hx-swap="outerHTML"
529 hx-confirm="Delete this blog post?">Delete</button>
530 </div>
531 </td>
532 </tr>
533 {% endfor %}
534 </tbody>
535 </table>
536 {% endif %}
537