Skip to main content

max / makenotwork

Add image upload to project settings and item details tabs Project settings tab now has a Project Image section at the top with preview and Change Image button. Item details tab has an Item Image section between description and type. Both use the existing presign/confirm S3 flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-10 23:04 UTC
Commit: cadaffc8017239b52d6e30abc76f499dc5ba957e
Parent: 007014a
4 files changed, +136 insertions, -4 deletions
@@ -15,6 +15,7 @@
15 15 initSections(itemId);
16 16 initTagSearch(itemId);
17 17 initAiTierToggle();
18 + initItemImage(itemId);
18 19 }
19 20
20 21 // ── Bundles ──
@@ -350,6 +351,52 @@
350 351 }
351 352 }
352 353
354 + // ── Item Image ──
355 +
356 + function initItemImage(itemId) {
357 + var input = document.getElementById('item-image-input');
358 + if (!input) return;
359 + input.addEventListener('change', function() {
360 + var file = this.files[0];
361 + if (!file) return;
362 + var status = document.getElementById('item-image-status');
363 + status.textContent = 'Uploading...';
364 +
365 + fetch('/api/items/image/presign', {
366 + method: 'POST',
367 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
368 + body: JSON.stringify({ item_id: itemId, file_name: file.name, content_type: file.type || 'image/jpeg' })
369 + })
370 + .then(function(res) { if (!res.ok) throw new Error('Presign failed'); return res.json(); })
371 + .then(function(data) {
372 + var xhr = new XMLHttpRequest();
373 + xhr.open('PUT', data.upload_url);
374 + xhr.setRequestHeader('Content-Type', file.type || 'image/jpeg');
375 + if (data.cache_control) xhr.setRequestHeader('Cache-Control', data.cache_control);
376 + return new Promise(function(resolve, reject) {
377 + xhr.onload = function() { xhr.status < 300 ? resolve(data.s3_key) : reject(new Error('Upload failed')); };
378 + xhr.onerror = function() { reject(new Error('Network error')); };
379 + xhr.send(file);
380 + });
381 + })
382 + .then(function(s3Key) {
383 + return fetch('/api/items/image/confirm', {
384 + method: 'POST',
385 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
386 + body: JSON.stringify({ item_id: itemId, s3_key: s3Key })
387 + });
388 + })
389 + .then(function(res) { if (!res.ok) throw new Error('Confirm failed'); return res.json(); })
390 + .then(function(data) {
391 + status.textContent = 'Saved.';
392 + var preview = document.getElementById('item-image-preview');
393 + preview.innerHTML = '<img src="' + data.image_url + '" alt="Item image" style="width:100%;height:100%;object-fit:cover;">';
394 + setTimeout(function() { status.textContent = ''; }, 2000);
395 + })
396 + .catch(function(err) { status.textContent = err.message; });
397 + });
398 + }
399 +
353 400 // ── Shared ──
354 401
355 402 function escapeHtml(s) {
@@ -122,8 +122,8 @@
122 122 {% endblock %}
123 123
124 124 {% block scripts %}
125 - <script src="/static/upload.js?v=0517"></script>
126 - <script src="/static/media-picker.js?v=0517"></script>
127 - <script src="/static/item-details.js?v=0517"></script>
128 - <script src="/static/item-upload.js?v=0517"></script>
125 + <script src="/static/upload.js?v=0518"></script>
126 + <script src="/static/media-picker.js?v=0518"></script>
127 + <script src="/static/item-details.js?v=0518"></script>
128 + <script src="/static/item-upload.js?v=0518"></script>
129 129 {% endblock %}
@@ -16,6 +16,26 @@
16 16 </div>
17 17
18 18 <div class="form-group">
19 + <label>Item Image</label>
20 + <div style="display: flex; align-items: flex-start; gap: 1.5rem;">
21 + <div id="item-image-preview" style="width: 120px; height: 120px; background: var(--border); display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
22 + {% match item.cover_image_url %}
23 + {% when Some with (url) %}
24 + <img src="{{ url }}" alt="Item image" style="width: 100%; height: 100%; object-fit: cover;">
25 + {% when None %}
26 + <span style="opacity: 0.4; font-size: 0.8rem;">No image</span>
27 + {% endmatch %}
28 + </div>
29 + <div>
30 + <p style="font-size: 0.85rem; opacity: 0.7; margin: 0 0 0.5rem;">Square, at least 400x400px. JPG, PNG, or WebP.</p>
31 + <input type="file" id="item-image-input" accept="image/jpeg,image/png,image/webp" style="display: none;">
32 + <button type="button" class="secondary" onclick="document.getElementById('item-image-input').click()" style="padding: 0.4rem 0.8rem;">Change Image</button>
33 + <span id="item-image-status" style="margin-left: 0.5rem; font-size: 0.85rem;"></span>
34 + </div>
35 + </div>
36 + </div>
37 +
38 + <div class="form-group">
19 39 <label for="item-type">Item Type</label>
20 40 <select id="item-type">
21 41 <option>Plugin</option>
@@ -1,6 +1,71 @@
1 1 <div class="tab-docs"><a href="/docs/projects">Docs: Projects &rarr;</a></div>
2 2
3 3 <div class="form-section">
4 + <h2>Project Image</h2>
5 + <div style="display: flex; align-items: flex-start; gap: 1.5rem;">
6 + <div id="project-image-preview" style="width: 120px; height: 120px; background: var(--border); display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
7 + {% if project.cover_image_url.is_empty() %}
8 + <span style="opacity: 0.4; font-size: 0.8rem;">No image</span>
9 + {% else %}
10 + <img src="{{ project.cover_image_url }}" alt="Project image" style="width: 100%; height: 100%; object-fit: cover;">
11 + {% endif %}
12 + </div>
13 + <div>
14 + <p style="font-size: 0.85rem; opacity: 0.7; margin: 0 0 0.5rem;">Square, at least 400x400px. JPG, PNG, or WebP.</p>
15 + <input type="file" id="project-image-input" accept="image/jpeg,image/png,image/webp" style="display: none;">
16 + <button type="button" class="secondary" onclick="document.getElementById('project-image-input').click()" style="padding: 0.4rem 0.8rem;">Change Image</button>
17 + <span id="project-image-status" style="margin-left: 0.5rem; font-size: 0.85rem;"></span>
18 + </div>
19 + </div>
20 + </div>
21 + <script>
22 + (function() {
23 + var input = document.getElementById('project-image-input');
24 + if (!input) return;
25 + var projectId = '{{ project_id }}';
26 + input.addEventListener('change', function() {
27 + var file = this.files[0];
28 + if (!file) return;
29 + var status = document.getElementById('project-image-status');
30 + status.textContent = 'Uploading...';
31 +
32 + fetch('/api/projects/image/presign', {
33 + method: 'POST',
34 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
35 + body: JSON.stringify({ project_id: projectId, file_name: file.name, content_type: file.type || 'image/jpeg' })
36 + })
37 + .then(function(res) { if (!res.ok) throw new Error('Presign failed'); return res.json(); })
38 + .then(function(data) {
39 + var xhr = new XMLHttpRequest();
40 + xhr.open('PUT', data.upload_url);
41 + xhr.setRequestHeader('Content-Type', file.type || 'image/jpeg');
42 + if (data.cache_control) xhr.setRequestHeader('Cache-Control', data.cache_control);
43 + return new Promise(function(resolve, reject) {
44 + xhr.onload = function() { xhr.status < 300 ? resolve(data.s3_key) : reject(new Error('Upload failed')); };
45 + xhr.onerror = function() { reject(new Error('Network error')); };
46 + xhr.send(file);
47 + });
48 + })
49 + .then(function(s3Key) {
50 + return fetch('/api/projects/image/confirm', {
51 + method: 'POST',
52 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
53 + body: JSON.stringify({ project_id: projectId, s3_key: s3Key })
54 + });
55 + })
56 + .then(function(res) { if (!res.ok) throw new Error('Confirm failed'); return res.json(); })
57 + .then(function(data) {
58 + status.textContent = 'Saved.';
59 + var preview = document.getElementById('project-image-preview');
60 + preview.innerHTML = '<img src="' + data.image_url + '" alt="Project image" style="width:100%;height:100%;object-fit:cover;">';
61 + setTimeout(function() { status.textContent = ''; }, 2000);
62 + })
63 + .catch(function(err) { status.textContent = err.message; });
64 + });
65 + })();
66 + </script>
67 +
68 + <div class="form-section">
4 69 <h2>Project Information</h2>
5 70
6 71 <form id="project-info-form" onsubmit="return saveProjectInfo(event, '{{ project.id }}')">