Skip to main content

max / makenotwork

19.3 KB · 439 lines History Blame Raw
1 <div class="tab-docs"><a href="/docs/projects">Docs: Projects &rarr;</a></div>
2
3 <div class="form-section">
4 <h2 class="subsection-title">Project Image</h2>
5 <div class="proj-image-row">
6 <div id="project-image-preview" class="proj-image-preview">
7 {% match project.cover_image_url %}
8 {% when Some with (url) %}
9 <img src="{{ url }}" alt="Project image">
10 {% when None %}
11 <span class="proj-image-empty">No image</span>
12 {% endmatch %}
13 </div>
14 <div>
15 <p class="proj-image-hint">Square, at least 400x400px. JPG, PNG, or WebP.</p>
16 <input type="file" id="project-image-input" class="sr-only" accept="image/jpeg,image/png,image/webp">
17 <button type="button" class="btn-secondary btn-compact" onclick="document.getElementById('project-image-input').click()">Change Image</button>
18 <span id="project-image-status" class="field-status"></span>
19 </div>
20 </div>
21 </div>
22 <script>
23 (function() {
24 var input = document.getElementById('project-image-input');
25 if (!input) return;
26 var projectId = '{{ project_id }}';
27 input.addEventListener('change', function() {
28 var file = this.files[0];
29 if (!file) return;
30 var status = document.getElementById('project-image-status');
31 status.textContent = 'Uploading...';
32
33 fetch('/api/projects/image/presign', {
34 method: 'POST',
35 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
36 body: JSON.stringify({ project_id: projectId, file_name: file.name, content_type: file.type || 'image/jpeg' })
37 })
38 .then(function(res) { if (!res.ok) throw new Error('Presign failed'); return res.json(); })
39 .then(function(data) {
40 var xhr = new XMLHttpRequest();
41 xhr.open('PUT', data.upload_url);
42 xhr.setRequestHeader('Content-Type', file.type || 'image/jpeg');
43 if (data.cache_control) xhr.setRequestHeader('Cache-Control', data.cache_control);
44 return new Promise(function(resolve, reject) {
45 xhr.onload = function() { xhr.status < 300 ? resolve(data.s3_key) : reject(new Error('Upload failed')); };
46 xhr.onerror = function() { reject(new Error('Network error')); };
47 xhr.send(file);
48 });
49 })
50 .then(function(s3Key) {
51 return fetch('/api/projects/image/confirm', {
52 method: 'POST',
53 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
54 body: JSON.stringify({ project_id: projectId, s3_key: s3Key })
55 });
56 })
57 .then(function(res) { if (!res.ok) throw new Error('Confirm failed'); return res.json(); })
58 .then(function(data) {
59 status.textContent = 'Saved.';
60 var preview = document.getElementById('project-image-preview');
61 preview.innerHTML = '<img src="' + data.image_url + '" alt="Project image">';
62 setTimeout(function() { status.textContent = ''; }, 2000);
63 })
64 .catch(function(err) { status.textContent = err.message; });
65 });
66 })();
67 </script>
68
69 <div class="form-section">
70 <h2 class="subsection-title">Gallery Images</h2>
71 <p class="proj-image-hint">Up to 8 extra images shown in a carousel on your public project page. The Project Image above stays the card/preview image.</p>
72 <div id="project-gallery-list" class="gallery-manager-list"></div>
73 <input type="file" id="project-gallery-input" class="sr-only" accept="image/jpeg,image/png,image/webp" multiple>
74 <button type="button" class="btn-secondary btn-compact" onclick="document.getElementById('project-gallery-input').click()">Add Images</button>
75 <span id="project-gallery-status" class="field-status"></span>
76 </div>
77 <script>
78 // This tab can arrive via an HTMX swap (DOMContentLoaded won't fire), so init
79 // immediately, lazy-loading gallery.js if it isn't present yet.
80 (function() {
81 function go() {
82 initGalleryManager({
83 targetType: 'project',
84 targetId: '{{ project_id }}',
85 listId: 'project-gallery-list',
86 inputId: 'project-gallery-input',
87 statusId: 'project-gallery-status'
88 });
89 }
90 if (window.initGalleryManager) { go(); return; }
91 var s = document.createElement('script');
92 s.src = '/static/gallery.js';
93 s.onload = go;
94 document.head.appendChild(s);
95 })();
96 </script>
97
98 <div class="form-section">
99 <h2 class="subsection-title">Project Information</h2>
100
101 <form id="project-info-form" onsubmit="return saveProjectInfo(event, '{{ project.id }}')">
102 <div class="form-group">
103 <label for="project-name">Project Name</label>
104 <input type="text" id="project-name" name="title" value="{{ project.title }}" placeholder="Project name" title="The public name of your project" required>
105 </div>
106
107 <div class="form-group">
108 <label for="project-description">Description</label>
109 <textarea id="project-description" name="description" placeholder="Describe your project..." title="A brief description shown to visitors">{{ project.description }}</textarea>
110 </div>
111
112 <div class="form-group">
113 <label for="settings-category">Category</label>
114 <div class="suggestion-input" id="settings-category-suggestion">
115 <input type="text" id="settings-category" name="category" value="{{ category_name }}" placeholder="What kind of project is this?" autocomplete="off">
116 <div class="suggestion-dropdown" id="settings-category-dropdown"></div>
117 </div>
118 <div class="hint">Choose an existing category or type a new one. Leave blank to clear.</div>
119 </div>
120
121 <button class="btn-primary" type="submit">
122 Save Changes
123 <span id="project-spinner" class="htmx-indicator"> ...</span>
124 </button>
125 <span id="project-save-status"></span>
126 </form>
127 </div>
128
129 <div class="form-section">
130 <h2 class="subsection-title">Monetization</h2>
131 <p class="section-lead">
132 How visitors access this project. 0% platform fee: only ~3% payment processing.
133 Changing models is safe but does not refund or cancel existing purchases or subscriptions.
134 </p>
135
136 <form id="project-pricing-form" onsubmit="return saveProjectPricing(event, '{{ project.id }}')">
137 <div class="form-group">
138 <label for="settings-pricing-model">Pricing model</label>
139 <select id="settings-pricing-model" name="pricing_model" onchange="updateSettingsPricingUI()">
140 <option value="free"{% if pricing_model == "free" %} selected{% endif %}>Free: anyone can access</option>
141 <option value="buy_once"{% if pricing_model == "buy_once" %} selected{% endif %}>One-time purchase</option>
142 <option value="pwyw"{% if pricing_model == "pwyw" %} selected{% endif %}>Pay what you want</option>
143 <option value="subscription"{% if pricing_model == "subscription" %} selected{% endif %}>Membership: recurring monthly</option>
144 </select>
145 </div>
146
147 <div id="settings-buy-once-fields" class="hidden">
148 <div class="form-group">
149 <label for="settings-price-dollars">Price ($)</label>
150 <input type="number" id="settings-price-dollars" name="price_dollars" min="0.50" step="0.01"
151 placeholder="9.99" value="{{ price_dollars }}">
152 </div>
153 </div>
154
155 <div id="settings-pwyw-fields" class="hidden">
156 <div class="form-group">
157 <label for="settings-pwyw-min-dollars">Minimum price ($, 0 for no minimum)</label>
158 <input type="number" id="settings-pwyw-min-dollars" name="pwyw_min_dollars" min="0" step="0.01"
159 placeholder="0.00" value="{{ pwyw_min_dollars }}">
160 </div>
161 </div>
162
163 <div id="settings-subscription-note" class="hidden subscription-note">
164 <p>
165 Manage tiers in the <strong>Subscriptions</strong> tab.
166 </p>
167 </div>
168
169 <button class="btn-primary" type="submit">Save Monetization</button>
170 <span id="project-pricing-status" class="field-status"></span>
171 </form>
172 </div>
173
174 <div class="form-section">
175 <h2 class="subsection-title">Features</h2>
176 <p class="section-lead">Platform tools enabled for this project. Changes take effect immediately.</p>
177
178 <div class="type-grid" id="features-grid">
179 {% for (value, label, desc) in project_features %}
180 <label class="type-card">
181 <input type="checkbox" name="feature" value="{{ value }}"
182 {% if features.contains(&value.to_string()) %}checked{% endif %}
183 onchange="updateFeatures('{{ project.id }}')">
184 <span class="type-card-inner">
185 <span class="type-card-label">{{ label }}</span>
186 <span class="type-card-desc">{{ desc }}</span>
187 </span>
188 </label>
189 {% endfor %}
190 </div>
191 <span id="features-save-status" class="features-grid-status"></span>
192 </div>
193
194 <div class="section-group-label">Project Management</div>
195
196 <div class="form-section">
197 <h2 class="subsection-title">Delete Project</h2>
198 <p class="section-lead">
199 Deleting this project is permanent and cannot be undone.
200 </p>
201 <button class="btn-danger"
202 hx-delete="/api/projects/{{ project.id }}"
203 hx-confirm="Delete project '{{ project.title }}'? This cannot be undone."
204 hx-on::after-request="if(event.detail.successful) window.location.href='/dashboard'">
205 Delete Project
206 </button>
207 </div>
208
209 <!-- Pages (project-level markdown sections) -->
210 <details class="content-section pages-toggle" id="psections-management"{% if !sections.is_empty() %} open{% endif %}>
211 <summary>
212 <h2 class="subsection-title">Pages (<span id="psection-count">{{ sections.len() }}</span>)</h2>
213 </summary>
214 <p class="pages-intro">Markdown pages that apply to your whole project: Privacy Policy, Terms, FAQ, etc. They appear as tabs on your public project page and are linkable via <code>#section-&lt;slug&gt;</code>. Max 10.</p>
215
216 <div id="psections-list">
217 {% if sections.is_empty() %}
218 <p id="psections-empty" class="empty-state">No pages yet. Common pages: Privacy Policy, Terms of Service, FAQ, Support.</p>
219 {% else %}
220 {% for section in sections %}
221 <div class="psection-row" data-id="{{ section.id }}">
222 <span class="psection-row-title">{{ section.title }}</span>
223 <code class="psection-row-anchor">#section-{{ section.slug }}</code>
224 <span class="psection-row-length">{{ section.body.chars().count() }} chars</span>
225 <button type="button" class="btn-secondary psection-edit-btn" data-id="{{ section.id }}"
226 data-title="{{ section.title }}">Edit</button>
227 <button type="button" class="btn-secondary psection-del-btn" data-id="{{ section.id }}">Delete</button>
228 </div>
229 <textarea data-body-for="{{ section.id }}" class="hidden">{{ section.body }}</textarea>
230 {% endfor %}
231 {% endif %}
232 </div>
233
234 <details class="psection-add-details" id="psection-add-details">
235 <summary>Add Page</summary>
236 <div class="psection-add-fields">
237 <div class="form-group">
238 <label for="new-psec-title">Title</label>
239 <input type="text" id="new-psec-title" placeholder="e.g. Privacy Policy, Terms..." autocomplete="off">
240 </div>
241 <div class="form-group">
242 <label for="new-psec-body">Body (Markdown)</label>
243 <textarea id="new-psec-body" rows="10" placeholder="Page content..."></textarea>
244 </div>
245 <button type="button" class="btn-secondary" id="add-psec-btn" data-project-id="{{ project.id }}">Add Page</button>
246 <span id="psec-add-status" class="field-status"></span>
247 </div>
248 </details>
249
250 <div id="psection-edit-modal" class="psection-edit-modal hidden">
251 <input type="hidden" id="edit-psec-id">
252 <div class="form-group">
253 <label for="edit-psec-title">Title</label>
254 <input type="text" id="edit-psec-title" autocomplete="off">
255 </div>
256 <div class="form-group">
257 <label for="edit-psec-body">Body (Markdown)</label>
258 <textarea id="edit-psec-body" rows="10"></textarea>
259 </div>
260 <div class="psection-edit-actions">
261 <button type="button" class="btn-primary btn-compact" id="save-psec-btn">Save</button>
262 <button type="button" class="btn-secondary btn-compact" id="cancel-psec-btn">Cancel</button>
263 </div>
264 <span id="psec-edit-status" class="field-status"></span>
265 </div>
266 </details>
267 <script src="/static/project-sections.js" defer></script>
268 <script>
269 (function() {
270 // Category suggestion dropdown
271 var input = document.getElementById('settings-category');
272 var dropdown = document.getElementById('settings-category-dropdown');
273 if (!input || !dropdown) return;
274 var debounce;
275
276 function showDropdown(items, query) {
277 dropdown.innerHTML = '';
278 items.forEach(function(c) {
279 var div = document.createElement('div');
280 div.className = 'suggestion-item';
281 div.textContent = c.name;
282 div.addEventListener('mousedown', function(e) {
283 e.preventDefault();
284 input.value = c.name;
285 dropdown.classList.remove('open');
286 });
287 dropdown.appendChild(div);
288 });
289 var q = query.trim();
290 if (q.length > 0 && !items.some(function(c) { return c.name.toLowerCase() === q.toLowerCase(); })) {
291 var create = document.createElement('div');
292 create.className = 'suggestion-item suggestion-create';
293 create.textContent = 'Create: ' + q;
294 create.addEventListener('mousedown', function(e) {
295 e.preventDefault();
296 input.value = q;
297 dropdown.classList.remove('open');
298 });
299 dropdown.appendChild(create);
300 }
301 if (dropdown.children.length > 0) {
302 dropdown.classList.add('open');
303 } else {
304 dropdown.classList.remove('open');
305 }
306 }
307
308 input.addEventListener('input', function() {
309 clearTimeout(debounce);
310 var q = input.value.trim();
311 if (q.length < 1) { dropdown.classList.remove('open'); return; }
312 debounce = setTimeout(function() {
313 fetch('/api/categories/search?q=' + encodeURIComponent(q))
314 .then(function(r) { return r.json(); })
315 .then(function(cats) { showDropdown(cats, q); })
316 .catch(function() {});
317 }, 200);
318 });
319
320 input.addEventListener('focus', function() {
321 if (dropdown.children.length > 0) dropdown.classList.add('open');
322 });
323
324 input.addEventListener('blur', function() {
325 setTimeout(function() { dropdown.classList.remove('open'); }, 150);
326 });
327 })();
328
329 // Project info save
330 function saveProjectInfo(e, projectId) {
331 e.preventDefault();
332 var status = document.getElementById('project-save-status');
333 var data = {
334 title: document.getElementById('project-name').value,
335 description: document.getElementById('project-description').value,
336 category: document.getElementById('settings-category').value
337 };
338 fetch('/api/projects/' + projectId, {
339 method: 'PUT',
340 headers: {'Content-Type': 'application/json', ...csrfHeaders()},
341 body: JSON.stringify(data)
342 }).then(function(r) {
343 if (r.ok) {
344 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
345 setTimeout(function() { if (status) status.textContent = ''; }, 2000);
346 } else {
347 r.text().then(function(body) {
348 var msg = 'Save failed';
349 try { msg = JSON.parse(body).error || msg; } catch(_) {}
350 if (status) { status.textContent = msg; status.style.color = 'var(--error-color)'; }
351 });
352 }
353 }).catch(function() {
354 if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; }
355 });
356 return false;
357 }
358
359 // Monetization save
360 function updateSettingsPricingUI() {
361 var model = document.getElementById('settings-pricing-model').value;
362 var sections = [
363 { id: 'settings-buy-once-fields', active: model === 'buy_once' },
364 { id: 'settings-pwyw-fields', active: model === 'pwyw' },
365 { id: 'settings-subscription-note', active: model === 'subscription' }
366 ];
367 sections.forEach(function(s) {
368 var el = document.getElementById(s.id);
369 if (el) el.classList.toggle('hidden', !s.active);
370 });
371 }
372 updateSettingsPricingUI();
373
374 function saveProjectPricing(e, projectId) {
375 e.preventDefault();
376 var status = document.getElementById('project-pricing-status');
377 var model = document.getElementById('settings-pricing-model').value;
378 var data = { pricing_model: model };
379
380 if (model === 'buy_once') {
381 var p = parseFloat(document.getElementById('settings-price-dollars').value);
382 if (!(p >= 0.50)) {
383 if (status) { status.textContent = 'Price must be at least $0.50'; status.style.color = 'var(--error-color)'; }
384 return false;
385 }
386 data.price_dollars = p;
387 } else if (model === 'pwyw') {
388 var v = document.getElementById('settings-pwyw-min-dollars').value;
389 data.pwyw_min_dollars = v === '' ? 0 : parseFloat(v);
390 }
391
392 fetch('/api/projects/' + projectId, {
393 method: 'PUT',
394 headers: {'Content-Type': 'application/json', ...csrfHeaders()},
395 body: JSON.stringify(data)
396 }).then(function(r) {
397 if (r.ok) {
398 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
399 setTimeout(function() { if (status) status.textContent = ''; }, 2000);
400 } else {
401 r.text().then(function(body) {
402 var msg = 'Save failed';
403 try { msg = JSON.parse(body).error || msg; } catch(_) {}
404 if (status) { status.textContent = msg; status.style.color = 'var(--error-color)'; }
405 });
406 }
407 }).catch(function() {
408 if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; }
409 });
410 return false;
411 }
412
413 // Features update
414 function updateFeatures(projectId) {
415 var checkboxes = document.querySelectorAll('#features-grid input[type="checkbox"]');
416 var selected = [];
417 checkboxes.forEach(function(cb) {
418 if (cb.checked) selected.push(cb.value);
419 });
420 var status = document.getElementById('features-save-status');
421
422 fetch('/api/projects/' + projectId, {
423 method: 'PUT',
424 headers: {'Content-Type': 'application/json', ...csrfHeaders()},
425 body: JSON.stringify({features: selected})
426 }).then(function(r) {
427 if (r.ok) {
428 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
429 // Reload the page so the tab bar reflects new features
430 setTimeout(function() { window.location.reload(); }, 300);
431 } else {
432 if (status) { status.textContent = 'Save failed'; status.style.color = 'var(--error-color)'; }
433 }
434 }).catch(function() {
435 if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; }
436 });
437 }
438 </script>
439