Skip to main content

max / makenotwork

18.1 KB · 410 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">Project Information</h2>
71
72 <form id="project-info-form" onsubmit="return saveProjectInfo(event, '{{ project.id }}')">
73 <div class="form-group">
74 <label for="project-name">Project Name</label>
75 <input type="text" id="project-name" name="title" value="{{ project.title }}" placeholder="Project name" title="The public name of your project" required>
76 </div>
77
78 <div class="form-group">
79 <label for="project-description">Description</label>
80 <textarea id="project-description" name="description" placeholder="Describe your project..." title="A brief description shown to visitors">{{ project.description }}</textarea>
81 </div>
82
83 <div class="form-group">
84 <label for="settings-category">Category</label>
85 <div class="suggestion-input" id="settings-category-suggestion">
86 <input type="text" id="settings-category" name="category" value="{{ category_name }}" placeholder="What kind of project is this?" autocomplete="off">
87 <div class="suggestion-dropdown" id="settings-category-dropdown"></div>
88 </div>
89 <div class="hint">Choose an existing category or type a new one. Leave blank to clear.</div>
90 </div>
91
92 <button class="btn-primary" type="submit">
93 Save Changes
94 <span id="project-spinner" class="htmx-indicator"> ...</span>
95 </button>
96 <span id="project-save-status"></span>
97 </form>
98 </div>
99
100 <div class="form-section">
101 <h2 class="subsection-title">Monetization</h2>
102 <p class="section-lead">
103 How visitors access this project. 0% platform fee: only ~3% payment processing.
104 Changing models is safe but does not refund or cancel existing purchases or subscriptions.
105 </p>
106
107 <form id="project-pricing-form" onsubmit="return saveProjectPricing(event, '{{ project.id }}')">
108 <div class="form-group">
109 <label for="settings-pricing-model">Pricing model</label>
110 <select id="settings-pricing-model" name="pricing_model" onchange="updateSettingsPricingUI()">
111 <option value="free"{% if pricing_model == "free" %} selected{% endif %}>Free: anyone can access</option>
112 <option value="buy_once"{% if pricing_model == "buy_once" %} selected{% endif %}>One-time purchase</option>
113 <option value="pwyw"{% if pricing_model == "pwyw" %} selected{% endif %}>Pay what you want</option>
114 <option value="subscription"{% if pricing_model == "subscription" %} selected{% endif %}>Membership: recurring monthly</option>
115 </select>
116 </div>
117
118 <div id="settings-buy-once-fields" class="hidden">
119 <div class="form-group">
120 <label for="settings-price-dollars">Price ($)</label>
121 <input type="number" id="settings-price-dollars" name="price_dollars" min="0.50" step="0.01"
122 placeholder="9.99" value="{{ price_dollars }}">
123 </div>
124 </div>
125
126 <div id="settings-pwyw-fields" class="hidden">
127 <div class="form-group">
128 <label for="settings-pwyw-min-dollars">Minimum price ($, 0 for no minimum)</label>
129 <input type="number" id="settings-pwyw-min-dollars" name="pwyw_min_dollars" min="0" step="0.01"
130 placeholder="0.00" value="{{ pwyw_min_dollars }}">
131 </div>
132 </div>
133
134 <div id="settings-subscription-note" class="hidden subscription-note">
135 <p>
136 Manage tiers in the <strong>Subscriptions</strong> tab.
137 </p>
138 </div>
139
140 <button class="btn-primary" type="submit">Save Monetization</button>
141 <span id="project-pricing-status" class="field-status"></span>
142 </form>
143 </div>
144
145 <div class="form-section">
146 <h2 class="subsection-title">Features</h2>
147 <p class="section-lead">Platform tools enabled for this project. Changes take effect immediately.</p>
148
149 <div class="type-grid" id="features-grid">
150 {% for (value, label, desc) in project_features %}
151 <label class="type-card">
152 <input type="checkbox" name="feature" value="{{ value }}"
153 {% if features.contains(&value.to_string()) %}checked{% endif %}
154 onchange="updateFeatures('{{ project.id }}')">
155 <span class="type-card-inner">
156 <span class="type-card-label">{{ label }}</span>
157 <span class="type-card-desc">{{ desc }}</span>
158 </span>
159 </label>
160 {% endfor %}
161 </div>
162 <span id="features-save-status" class="features-grid-status"></span>
163 </div>
164
165 <div class="section-group-label">Project Management</div>
166
167 <div class="form-section">
168 <h2 class="subsection-title">Delete Project</h2>
169 <p class="section-lead">
170 Deleting this project is permanent and cannot be undone.
171 </p>
172 <button class="btn-danger"
173 hx-delete="/api/projects/{{ project.id }}"
174 hx-confirm="Delete project '{{ project.title }}'? This cannot be undone."
175 hx-on::after-request="if(event.detail.successful) window.location.href='/dashboard'">
176 Delete Project
177 </button>
178 </div>
179
180 <!-- Pages (project-level markdown sections) -->
181 <details class="content-section pages-toggle" id="psections-management"{% if !sections.is_empty() %} open{% endif %}>
182 <summary>
183 <h2 class="subsection-title">Pages (<span id="psection-count">{{ sections.len() }}</span>)</h2>
184 </summary>
185 <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>
186
187 <div id="psections-list">
188 {% if sections.is_empty() %}
189 <p id="psections-empty" class="empty-state">No pages yet. Common pages: Privacy Policy, Terms of Service, FAQ, Support.</p>
190 {% else %}
191 {% for section in sections %}
192 <div class="psection-row" data-id="{{ section.id }}">
193 <span class="psection-row-title">{{ section.title }}</span>
194 <code class="psection-row-anchor">#section-{{ section.slug }}</code>
195 <span class="psection-row-length">{{ section.body.chars().count() }} chars</span>
196 <button type="button" class="btn-secondary psection-edit-btn" data-id="{{ section.id }}"
197 data-title="{{ section.title }}">Edit</button>
198 <button type="button" class="btn-secondary psection-del-btn" data-id="{{ section.id }}">Delete</button>
199 </div>
200 <textarea data-body-for="{{ section.id }}" class="hidden">{{ section.body }}</textarea>
201 {% endfor %}
202 {% endif %}
203 </div>
204
205 <details class="psection-add-details" id="psection-add-details">
206 <summary>Add Page</summary>
207 <div class="psection-add-fields">
208 <div class="form-group">
209 <label for="new-psec-title">Title</label>
210 <input type="text" id="new-psec-title" placeholder="e.g. Privacy Policy, Terms..." autocomplete="off">
211 </div>
212 <div class="form-group">
213 <label for="new-psec-body">Body (Markdown)</label>
214 <textarea id="new-psec-body" rows="10" placeholder="Page content..."></textarea>
215 </div>
216 <button type="button" class="btn-secondary" id="add-psec-btn" data-project-id="{{ project.id }}">Add Page</button>
217 <span id="psec-add-status" class="field-status"></span>
218 </div>
219 </details>
220
221 <div id="psection-edit-modal" class="psection-edit-modal hidden">
222 <input type="hidden" id="edit-psec-id">
223 <div class="form-group">
224 <label for="edit-psec-title">Title</label>
225 <input type="text" id="edit-psec-title" autocomplete="off">
226 </div>
227 <div class="form-group">
228 <label for="edit-psec-body">Body (Markdown)</label>
229 <textarea id="edit-psec-body" rows="10"></textarea>
230 </div>
231 <div class="psection-edit-actions">
232 <button type="button" class="btn-primary btn-compact" id="save-psec-btn">Save</button>
233 <button type="button" class="btn-secondary btn-compact" id="cancel-psec-btn">Cancel</button>
234 </div>
235 <span id="psec-edit-status" class="field-status"></span>
236 </div>
237 </details>
238 <script src="/static/project-sections.js" defer></script>
239 <script>
240 (function() {
241 // Category suggestion dropdown
242 var input = document.getElementById('settings-category');
243 var dropdown = document.getElementById('settings-category-dropdown');
244 if (!input || !dropdown) return;
245 var debounce;
246
247 function showDropdown(items, query) {
248 dropdown.innerHTML = '';
249 items.forEach(function(c) {
250 var div = document.createElement('div');
251 div.className = 'suggestion-item';
252 div.textContent = c.name;
253 div.addEventListener('mousedown', function(e) {
254 e.preventDefault();
255 input.value = c.name;
256 dropdown.classList.remove('open');
257 });
258 dropdown.appendChild(div);
259 });
260 var q = query.trim();
261 if (q.length > 0 && !items.some(function(c) { return c.name.toLowerCase() === q.toLowerCase(); })) {
262 var create = document.createElement('div');
263 create.className = 'suggestion-item suggestion-create';
264 create.textContent = 'Create: ' + q;
265 create.addEventListener('mousedown', function(e) {
266 e.preventDefault();
267 input.value = q;
268 dropdown.classList.remove('open');
269 });
270 dropdown.appendChild(create);
271 }
272 if (dropdown.children.length > 0) {
273 dropdown.classList.add('open');
274 } else {
275 dropdown.classList.remove('open');
276 }
277 }
278
279 input.addEventListener('input', function() {
280 clearTimeout(debounce);
281 var q = input.value.trim();
282 if (q.length < 1) { dropdown.classList.remove('open'); return; }
283 debounce = setTimeout(function() {
284 fetch('/api/categories/search?q=' + encodeURIComponent(q))
285 .then(function(r) { return r.json(); })
286 .then(function(cats) { showDropdown(cats, q); })
287 .catch(function() {});
288 }, 200);
289 });
290
291 input.addEventListener('focus', function() {
292 if (dropdown.children.length > 0) dropdown.classList.add('open');
293 });
294
295 input.addEventListener('blur', function() {
296 setTimeout(function() { dropdown.classList.remove('open'); }, 150);
297 });
298 })();
299
300 // Project info save
301 function saveProjectInfo(e, projectId) {
302 e.preventDefault();
303 var status = document.getElementById('project-save-status');
304 var data = {
305 title: document.getElementById('project-name').value,
306 description: document.getElementById('project-description').value,
307 category: document.getElementById('settings-category').value
308 };
309 fetch('/api/projects/' + projectId, {
310 method: 'PUT',
311 headers: {'Content-Type': 'application/json', ...csrfHeaders()},
312 body: JSON.stringify(data)
313 }).then(function(r) {
314 if (r.ok) {
315 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
316 setTimeout(function() { if (status) status.textContent = ''; }, 2000);
317 } else {
318 r.text().then(function(body) {
319 var msg = 'Save failed';
320 try { msg = JSON.parse(body).error || msg; } catch(_) {}
321 if (status) { status.textContent = msg; status.style.color = 'var(--error-color)'; }
322 });
323 }
324 }).catch(function() {
325 if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; }
326 });
327 return false;
328 }
329
330 // Monetization save
331 function updateSettingsPricingUI() {
332 var model = document.getElementById('settings-pricing-model').value;
333 var sections = [
334 { id: 'settings-buy-once-fields', active: model === 'buy_once' },
335 { id: 'settings-pwyw-fields', active: model === 'pwyw' },
336 { id: 'settings-subscription-note', active: model === 'subscription' }
337 ];
338 sections.forEach(function(s) {
339 var el = document.getElementById(s.id);
340 if (el) el.classList.toggle('hidden', !s.active);
341 });
342 }
343 updateSettingsPricingUI();
344
345 function saveProjectPricing(e, projectId) {
346 e.preventDefault();
347 var status = document.getElementById('project-pricing-status');
348 var model = document.getElementById('settings-pricing-model').value;
349 var data = { pricing_model: model };
350
351 if (model === 'buy_once') {
352 var p = parseFloat(document.getElementById('settings-price-dollars').value);
353 if (!(p >= 0.50)) {
354 if (status) { status.textContent = 'Price must be at least $0.50'; status.style.color = 'var(--error-color)'; }
355 return false;
356 }
357 data.price_dollars = p;
358 } else if (model === 'pwyw') {
359 var v = document.getElementById('settings-pwyw-min-dollars').value;
360 data.pwyw_min_dollars = v === '' ? 0 : parseFloat(v);
361 }
362
363 fetch('/api/projects/' + projectId, {
364 method: 'PUT',
365 headers: {'Content-Type': 'application/json', ...csrfHeaders()},
366 body: JSON.stringify(data)
367 }).then(function(r) {
368 if (r.ok) {
369 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
370 setTimeout(function() { if (status) status.textContent = ''; }, 2000);
371 } else {
372 r.text().then(function(body) {
373 var msg = 'Save failed';
374 try { msg = JSON.parse(body).error || msg; } catch(_) {}
375 if (status) { status.textContent = msg; status.style.color = 'var(--error-color)'; }
376 });
377 }
378 }).catch(function() {
379 if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; }
380 });
381 return false;
382 }
383
384 // Features update
385 function updateFeatures(projectId) {
386 var checkboxes = document.querySelectorAll('#features-grid input[type="checkbox"]');
387 var selected = [];
388 checkboxes.forEach(function(cb) {
389 if (cb.checked) selected.push(cb.value);
390 });
391 var status = document.getElementById('features-save-status');
392
393 fetch('/api/projects/' + projectId, {
394 method: 'PUT',
395 headers: {'Content-Type': 'application/json', ...csrfHeaders()},
396 body: JSON.stringify({features: selected})
397 }).then(function(r) {
398 if (r.ok) {
399 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
400 // Reload the page so the tab bar reflects new features
401 setTimeout(function() { window.location.reload(); }, 300);
402 } else {
403 if (status) { status.textContent = 'Save failed'; status.style.color = 'var(--error-color)'; }
404 }
405 }).catch(function() {
406 if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; }
407 });
408 }
409 </script>
410