max / makenotwork
5 files changed,
+278 insertions,
-308 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | use sqlx::PgPool; | |
| 2 | 2 | ||
| 3 | - | use crate::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 3 | + | use crate::db::{SyncAppId, UserId}; | |
| 4 | 4 | use crate::error::Result; | |
| 5 | 5 | ||
| 6 | 6 | // ── Sync Keys ── |
| @@ -0,0 +1,256 @@ | |||
| 1 | + | /* Makenotwork — SyncKit dashboard tabs (project + user). | |
| 2 | + | * | |
| 3 | + | * Loaded once globally (base.html), like synckit-billing.js. Each tab partial | |
| 4 | + | * calls window.initSyncKitTab(config) on render to wire its create form and | |
| 5 | + | * pass tab-specific bits (edit-control classes, whether Regenerate shows the | |
| 6 | + | * new key inline, the create-form mode). All table-row buttons call the global | |
| 7 | + | * syncKit* functions below. No template value is ever interpolated into a JS | |
| 8 | + | * string: ids/slugs/names arrive via data-* attributes, the project list via a | |
| 9 | + | * <template> of <option>s — so titles/slugs can't break out of a string. */ | |
| 10 | + | 'use strict'; | |
| 11 | + | ||
| 12 | + | (function () { | |
| 13 | + | // Tab-specific config, refreshed on each initSyncKitTab() call (tabs are | |
| 14 | + | // re-rendered HTMX partials; the script itself loads once). | |
| 15 | + | var cfg = { | |
| 16 | + | editInputClass: 'proj-synckit-slug-input input--sm w-120', | |
| 17 | + | editBtnClass: 'proj-synckit-btn-row', | |
| 18 | + | regenInline: false, | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | function netError() { showToast('Network error. Please check your connection and try again.'); } | |
| 22 | + | function reloadTab() { document.getElementById('tab-synckit').click(); } | |
| 23 | + | ||
| 24 | + | // A "Copy" button that copies `text` and briefly flips its label. Built via | |
| 25 | + | // addEventListener so the secret key never lands in an inline onclick string. | |
| 26 | + | function makeCopyButton(text, className) { | |
| 27 | + | var btn = document.createElement('button'); | |
| 28 | + | btn.type = 'button'; | |
| 29 | + | btn.className = className; | |
| 30 | + | btn.textContent = 'Copy'; | |
| 31 | + | btn.addEventListener('click', function () { | |
| 32 | + | navigator.clipboard.writeText(text).then(function () { | |
| 33 | + | btn.textContent = 'Copied'; | |
| 34 | + | setTimeout(function () { btn.textContent = 'Copy'; }, 1500); | |
| 35 | + | }); | |
| 36 | + | }); | |
| 37 | + | return btn; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | window.syncKitCopyKey = function (event, key) { | |
| 41 | + | navigator.clipboard.writeText(key).then(function () { | |
| 42 | + | var btn = event.target; | |
| 43 | + | var orig = btn.textContent; | |
| 44 | + | btn.textContent = 'Copied'; | |
| 45 | + | setTimeout(function () { btn.textContent = orig; }, 1500); | |
| 46 | + | }); | |
| 47 | + | }; | |
| 48 | + | ||
| 49 | + | window.syncKitRegenKey = function (appId) { | |
| 50 | + | if (!confirm('Regenerate API key? All existing clients using the current key will stop working.')) return; | |
| 51 | + | fetch('/api/sync/apps/' + appId + '/regenerate-key', { | |
| 52 | + | method: 'POST', | |
| 53 | + | credentials: 'same-origin', | |
| 54 | + | headers: csrfHeaders(), | |
| 55 | + | }).then(function (res) { | |
| 56 | + | if (!res.ok) { showToast('Failed to regenerate key.'); return; } | |
| 57 | + | if (!cfg.regenInline) { reloadTab(); return; } | |
| 58 | + | // Project tab: reveal the new key inline with a copy button. | |
| 59 | + | return res.json().then(function (data) { | |
| 60 | + | var display = document.getElementById('synckit-key-display-' + appId); | |
| 61 | + | if (display && data.api_key) { | |
| 62 | + | var parent = display.parentElement; | |
| 63 | + | parent.innerHTML = ''; | |
| 64 | + | var code = document.createElement('code'); | |
| 65 | + | code.className = 'proj-synckit-code--wrap'; | |
| 66 | + | code.textContent = data.api_key; | |
| 67 | + | parent.appendChild(code); | |
| 68 | + | parent.appendChild(document.createTextNode(' ')); | |
| 69 | + | parent.appendChild(makeCopyButton(data.api_key, 'btn-small btn-secondary proj-synckit-btn-row')); | |
| 70 | + | parent.appendChild(document.createTextNode(' ')); | |
| 71 | + | var warn = document.createElement('span'); | |
| 72 | + | warn.className = 'form-hint proj-synckit-hint-warn'; | |
| 73 | + | warn.textContent = 'Save this key now: it cannot be shown again.'; | |
| 74 | + | parent.appendChild(warn); | |
| 75 | + | } | |
| 76 | + | showToast('Key regenerated. Copy it now: it will not be shown again.'); | |
| 77 | + | }); | |
| 78 | + | }).catch(netError); | |
| 79 | + | }; | |
| 80 | + | ||
| 81 | + | window.syncKitDeleteApp = function (appId, name) { | |
| 82 | + | if (!confirm('Delete sync app "' + name + '"? This will remove all devices and sync data for this app.')) return; | |
| 83 | + | fetch('/api/sync/apps/' + appId, { | |
| 84 | + | method: 'DELETE', | |
| 85 | + | credentials: 'same-origin', | |
| 86 | + | headers: csrfHeaders(), | |
| 87 | + | }).then(function (res) { | |
| 88 | + | if (res.ok) { reloadTab(); } else { showToast('Failed to delete app.'); } | |
| 89 | + | }).catch(netError); | |
| 90 | + | }; | |
| 91 | + | ||
| 92 | + | window.syncKitShowSlugForm = function (appId, currentSlug) { | |
| 93 | + | var cell = document.getElementById('synckit-slug-cell-' + appId); | |
| 94 | + | if (!cell) return; | |
| 95 | + | cell.innerHTML = ''; | |
| 96 | + | var input = document.createElement('input'); | |
| 97 | + | input.type = 'text'; | |
| 98 | + | input.value = currentSlug; | |
| 99 | + | input.placeholder = 'e.g. goingson'; | |
| 100 | + | input.className = cfg.editInputClass; | |
| 101 | + | cell.appendChild(input); | |
| 102 | + | cell.appendChild(document.createTextNode(' ')); | |
| 103 | + | var saveBtn = document.createElement('button'); | |
| 104 | + | saveBtn.className = 'btn-small ' + cfg.editBtnClass; | |
| 105 | + | saveBtn.textContent = 'Save'; | |
| 106 | + | saveBtn.addEventListener('click', function () { | |
| 107 | + | var slug = input.value.trim(); | |
| 108 | + | if (!slug) return; | |
| 109 | + | fetch('/api/sync/apps/' + appId + '/slug', { | |
| 110 | + | method: 'PUT', | |
| 111 | + | credentials: 'same-origin', | |
| 112 | + | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 113 | + | body: JSON.stringify({ slug: slug }), | |
| 114 | + | }).then(function (res) { | |
| 115 | + | if (res.ok) { | |
| 116 | + | reloadTab(); | |
| 117 | + | showToast('Update slug set to "' + slug + '".'); | |
| 118 | + | } else { | |
| 119 | + | res.text().then(function (t) { showToast(t || 'Failed to set slug.'); }); | |
| 120 | + | } | |
| 121 | + | }).catch(netError); | |
| 122 | + | }); | |
| 123 | + | cell.appendChild(saveBtn); | |
| 124 | + | cell.appendChild(document.createTextNode(' ')); | |
| 125 | + | var cancelBtn = document.createElement('button'); | |
| 126 | + | cancelBtn.className = 'btn-small secondary ' + cfg.editBtnClass; | |
| 127 | + | cancelBtn.textContent = 'Cancel'; | |
| 128 | + | cancelBtn.addEventListener('click', reloadTab); | |
| 129 | + | cell.appendChild(cancelBtn); | |
| 130 | + | }; | |
| 131 | + | ||
| 132 | + | window.syncKitShowLinkForm = function (appId) { | |
| 133 | + | var cell = document.getElementById('synckit-link-cell-' + appId); | |
| 134 | + | if (!cell) return; | |
| 135 | + | cell.innerHTML = ''; | |
| 136 | + | var select = document.createElement('select'); | |
| 137 | + | select.id = 'synckit-link-select-' + appId; | |
| 138 | + | select.className = 'user-synckit-edit-select input--sm'; | |
| 139 | + | var noneOpt = document.createElement('option'); | |
| 140 | + | noneOpt.value = ''; | |
| 141 | + | noneOpt.textContent = 'None'; | |
| 142 | + | select.appendChild(noneOpt); | |
| 143 | + | // Options come from a server-rendered <template> (HTML-escaped), not from | |
| 144 | + | // values interpolated into this script. | |
| 145 | + | var tpl = document.getElementById('synckit-projects-options'); | |
| 146 | + | if (tpl && tpl.content) { | |
| 147 | + | tpl.content.querySelectorAll('option').forEach(function (opt) { | |
| 148 | + | select.appendChild(opt.cloneNode(true)); | |
| 149 | + | }); | |
| 150 | + | } | |
| 151 | + | cell.appendChild(select); | |
| 152 | + | cell.appendChild(document.createTextNode(' ')); | |
| 153 | + | var saveBtn = document.createElement('button'); | |
| 154 | + | saveBtn.className = 'btn-small user-synckit-edit-btn'; | |
| 155 | + | saveBtn.textContent = 'Save'; | |
| 156 | + | saveBtn.addEventListener('click', function () { window.syncKitSaveLink(appId); }); | |
| 157 | + | cell.appendChild(saveBtn); | |
| 158 | + | cell.appendChild(document.createTextNode(' ')); | |
| 159 | + | var cancelBtn = document.createElement('button'); | |
| 160 | + | cancelBtn.className = 'btn-small secondary user-synckit-edit-btn'; | |
| 161 | + | cancelBtn.textContent = 'Cancel'; | |
| 162 | + | cancelBtn.addEventListener('click', reloadTab); | |
| 163 | + | cell.appendChild(cancelBtn); | |
| 164 | + | }; | |
| 165 | + | ||
| 166 | + | window.syncKitSaveLink = function (appId) { | |
| 167 | + | var sel = document.getElementById('synckit-link-select-' + appId); | |
| 168 | + | if (!sel) return; | |
| 169 | + | var projectId = sel.value; | |
| 170 | + | fetch('/api/sync/apps/' + appId + '/link', { | |
| 171 | + | method: 'PUT', | |
| 172 | + | credentials: 'same-origin', | |
| 173 | + | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 174 | + | body: JSON.stringify({ project_id: projectId || null, item_id: null }), | |
| 175 | + | }).then(function (res) { | |
| 176 | + | if (res.ok) { reloadTab(); } else { showToast('Failed to update link.'); } | |
| 177 | + | }).catch(netError); | |
| 178 | + | }; | |
| 179 | + | ||
| 180 | + | // Project tab: render the just-created app's key inline (one-time reveal). | |
| 181 | + | function showCreatedKeyInline(statusEl, apiKey) { | |
| 182 | + | statusEl.className = 'proj-synckit-create-status--reset'; | |
| 183 | + | statusEl.innerHTML = ''; | |
| 184 | + | var wrap = document.createElement('div'); | |
| 185 | + | wrap.className = 'proj-synckit-new-key'; | |
| 186 | + | var heading = document.createElement('p'); | |
| 187 | + | heading.className = 'proj-synckit-new-key-heading'; | |
| 188 | + | heading.textContent = 'Save this API key now: it cannot be shown again.'; | |
| 189 | + | wrap.appendChild(heading); | |
| 190 | + | var code = document.createElement('code'); | |
| 191 | + | code.className = 'proj-synckit-code--selectable'; | |
| 192 | + | code.textContent = apiKey; | |
| 193 | + | wrap.appendChild(code); | |
| 194 | + | wrap.appendChild(document.createTextNode(' ')); | |
| 195 | + | wrap.appendChild(makeCopyButton(apiKey, 'btn-small btn-secondary proj-synckit-btn-row')); | |
| 196 | + | var doneBtn = document.createElement('button'); | |
| 197 | + | doneBtn.type = 'button'; | |
| 198 | + | doneBtn.className = 'btn-small proj-synckit-btn-tight'; | |
| 199 | + | doneBtn.textContent = 'Done'; | |
| 200 | + | doneBtn.addEventListener('click', reloadTab); | |
| 201 | + | wrap.appendChild(doneBtn); | |
| 202 | + | statusEl.appendChild(wrap); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | function wireCreateForm() { | |
| 206 | + | var form = document.getElementById('synckit-create-form'); | |
| 207 | + | if (!form) return; | |
| 208 | + | form.addEventListener('submit', function (e) { | |
| 209 | + | e.preventDefault(); | |
| 210 | + | var nameInput = document.getElementById('synckit-app-name'); | |
| 211 | + | var name = nameInput.value.trim(); | |
| 212 | + | if (!name) return; | |
| 213 | + | ||
| 214 | + | var body = { name: name }; | |
| 215 | + | if (cfg.create && cfg.create.mode === 'project') { | |
| 216 | + | if (cfg.create.projectId) body.project_id = cfg.create.projectId; | |
| 217 | + | } else { | |
| 218 | + | var projectSelect = document.getElementById('synckit-app-project'); | |
| 219 | + | var projectId = projectSelect ? projectSelect.value : ''; | |
| 220 | + | if (projectId) body.project_id = projectId; | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | fetch('/api/sync/apps', { | |
| 224 | + | method: 'POST', | |
| 225 | + | credentials: 'same-origin', | |
| 226 | + | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 227 | + | body: JSON.stringify(body), | |
| 228 | + | }).then(function (res) { | |
| 229 | + | var statusEl = document.getElementById('synckit-create-status'); | |
| 230 | + | if (res.ok) { | |
| 231 | + | // User tab just reloads; project tab reveals the key inline. | |
| 232 | + | if (!cfg.create || cfg.create.mode !== 'project') { reloadTab(); return; } | |
| 233 | + | return res.json().then(function (data) { | |
| 234 | + | if (statusEl && data.api_key) showCreatedKeyInline(statusEl, data.api_key); | |
| 235 | + | }); | |
| 236 | + | } | |
| 237 | + | return res.text().then(function (t) { | |
| 238 | + | if (!statusEl) return; | |
| 239 | + | statusEl.className = (cfg.create && cfg.create.errorClass) || 'proj-synckit-create-status--error'; | |
| 240 | + | statusEl.textContent = t || 'Failed to create app.'; | |
| 241 | + | }); | |
| 242 | + | }).catch(function () { | |
| 243 | + | var statusEl = document.getElementById('synckit-create-status'); | |
| 244 | + | if (!statusEl) return; | |
| 245 | + | statusEl.className = (cfg.create && cfg.create.errorClass) || 'proj-synckit-create-status--error'; | |
| 246 | + | statusEl.textContent = 'Network error. Please check your connection and try again.'; | |
| 247 | + | }); | |
| 248 | + | }); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | window.initSyncKitTab = function (config) { | |
| 252 | + | cfg = config || {}; | |
| 253 | + | if (window.initSyncKitBilling) window.initSyncKitBilling(); | |
| 254 | + | wireCreateForm(); | |
| 255 | + | }; | |
| 256 | + | })(); |
| @@ -48,6 +48,7 @@ | |||
| 48 | 48 | <script src="/static/carousel.js?v=0605"></script> | |
| 49 | 49 | <script src="/static/collections.js?v=0514"></script> | |
| 50 | 50 | <script src="/static/synckit-billing.js?v=0620"></script> | |
| 51 | + | <script src="/static/synckit-tabs.js?v=0623"></script> | |
| 51 | 52 | <script src="/static/whats_new.js?v=0522"></script> | |
| 52 | 53 | {% block scripts %}{% endblock %} | |
| 53 | 54 | </body> |
| @@ -55,7 +55,8 @@ | |||
| 55 | 55 | <span class="dimmed">-</span> | |
| 56 | 56 | {% endif %} | |
| 57 | 57 | <button class="btn-small btn-secondary proj-synckit-btn-tight" | |
| 58 | - | onclick="syncKitShowSlugForm('{{ app.id }}', '{{ app.slug.as_deref().unwrap_or_default() }}')">Set</button> | |
| 58 | + | data-slug="{{ app.slug.as_deref().unwrap_or_default() }}" | |
| 59 | + | onclick="syncKitShowSlugForm('{{ app.id }}', this.dataset.slug)">Set</button> | |
| 59 | 60 | </td> | |
| 60 | 61 | <td> | |
| 61 | 62 | <code id="synckit-key-display-{{ app.id }}" class="proj-synckit-code">{{ app.api_key_masked }}</code> | |
| @@ -88,137 +89,10 @@ | |||
| 88 | 89 | </div> | |
| 89 | 90 | ||
| 90 | 91 | <script> | |
| 91 | - | if (window.initSyncKitBilling) window.initSyncKitBilling(); | |
| 92 | - | function syncKitRegenKey(appId) { | |
| 93 | - | if (!confirm('Regenerate API key? All existing clients using the current key will stop working.')) return; | |
| 94 | - | fetch('/api/sync/apps/' + appId + '/regenerate-key', { | |
| 95 | - | method: 'POST', | |
| 96 | - | credentials: 'same-origin', | |
| 97 | - | headers: csrfHeaders() | |
| 98 | - | }).then(function(res) { | |
| 99 | - | if (res.ok) { | |
| 100 | - | return res.json().then(function(data) { | |
| 101 | - | // Show the new key with a copy button | |
| 102 | - | var display = document.getElementById('synckit-key-display-' + appId); | |
| 103 | - | if (display && data.api_key) { | |
| 104 | - | display.parentElement.innerHTML = | |
| 105 | - | '<code class="proj-synckit-code--wrap">' + data.api_key + '</code>' + | |
| 106 | - | ' <button class="btn-small btn-secondary proj-synckit-btn-row" ' + | |
| 107 | - | 'onclick="navigator.clipboard.writeText(\'' + data.api_key + '\').then(function(){this.textContent=\'Copied\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))">Copy</button>' + | |
| 108 | - | ' <span class="form-hint proj-synckit-hint-warn">Save this key now: it cannot be shown again.</span>'; | |
| 109 | - | } | |
| 110 | - | showToast('Key regenerated. Copy it now: it will not be shown again.'); | |
| 111 | - | }); | |
| 112 | - | } else { | |
| 113 | - | showToast('Failed to regenerate key.'); | |
| 114 | - | } | |
| 115 | - | }).catch(function() { | |
| 116 | - | showToast('Network error. Please check your connection and try again.'); | |
| 117 | - | }); | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | function syncKitDeleteApp(appId, name) { | |
| 121 | - | if (!confirm('Delete sync app "' + name + '"? This will remove all devices and sync data for this app.')) return; | |
| 122 | - | fetch('/api/sync/apps/' + appId, { | |
| 123 | - | method: 'DELETE', | |
| 124 | - | credentials: 'same-origin', | |
| 125 | - | headers: csrfHeaders() | |
| 126 | - | }).then(function(res) { | |
| 127 | - | if (res.ok) { | |
| 128 | - | document.getElementById('tab-synckit').click(); | |
| 129 | - | } else { | |
| 130 | - | showToast('Failed to delete app.'); | |
| 131 | - | } | |
| 132 | - | }).catch(function() { | |
| 133 | - | showToast('Network error. Please check your connection and try again.'); | |
| 134 | - | }); | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | function syncKitShowSlugForm(appId, currentSlug) { | |
| 138 | - | var cell = document.getElementById('synckit-slug-cell-' + appId); | |
| 139 | - | if (!cell) return; | |
| 140 | - | cell.innerHTML = ''; | |
| 141 | - | var input = document.createElement('input'); | |
| 142 | - | input.type = 'text'; | |
| 143 | - | input.value = currentSlug; | |
| 144 | - | input.placeholder = 'e.g. goingson'; | |
| 145 | - | input.className = 'proj-synckit-slug-input input--sm w-120'; | |
| 146 | - | cell.appendChild(input); | |
| 147 | - | cell.appendChild(document.createTextNode(' ')); | |
| 148 | - | var saveBtn = document.createElement('button'); | |
| 149 | - | saveBtn.className = 'btn-small proj-synckit-btn-row'; | |
| 150 | - | saveBtn.textContent = 'Save'; | |
| 151 | - | saveBtn.addEventListener('click', function() { | |
| 152 | - | var slug = input.value.trim(); | |
| 153 | - | if (!slug) return; | |
| 154 | - | fetch('/api/sync/apps/' + appId + '/slug', { | |
| 155 | - | method: 'PUT', | |
| 156 | - | credentials: 'same-origin', | |
| 157 | - | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 158 | - | body: JSON.stringify({ slug: slug }) | |
| 159 | - | }).then(function(res) { | |
| 160 | - | if (res.ok) { | |
| 161 | - | document.getElementById('tab-synckit').click(); | |
| 162 | - | showToast('Update slug set to "' + slug + '".'); | |
| 163 | - | } else { | |
| 164 | - | res.text().then(function(t) { showToast(t || 'Failed to set slug.'); }); | |
| 165 | - | } | |
| 166 | - | }).catch(function() { | |
| 167 | - | showToast('Network error. Please check your connection and try again.'); | |
| 168 | - | }); | |
| 169 | - | }); | |
| 170 | - | cell.appendChild(saveBtn); | |
| 171 | - | cell.appendChild(document.createTextNode(' ')); | |
| 172 | - | var cancelBtn = document.createElement('button'); | |
| 173 | - | cancelBtn.className = 'btn-small secondary proj-synckit-btn-row'; | |
| 174 | - | cancelBtn.textContent = 'Cancel'; | |
| 175 | - | cancelBtn.addEventListener('click', function() { document.getElementById('tab-synckit').click(); }); | |
| 176 | - | cell.appendChild(cancelBtn); | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | (function() { | |
| 180 | - | var form = document.getElementById('synckit-create-form'); | |
| 181 | - | if (!form) return; | |
| 182 | - | form.addEventListener('submit', function(e) { | |
| 183 | - | e.preventDefault(); | |
| 184 | - | var nameInput = document.getElementById('synckit-app-name'); | |
| 185 | - | var name = nameInput.value.trim(); | |
| 186 | - | if (!name) return; | |
| 187 | - | ||
| 188 | - | fetch('/api/sync/apps', { | |
| 189 | - | method: 'POST', | |
| 190 | - | credentials: 'same-origin', | |
| 191 | - | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 192 | - | body: JSON.stringify({ name: name, project_id: '{{ project_id }}' }) | |
| 193 | - | }).then(function(res) { | |
| 194 | - | if (res.ok) { | |
| 195 | - | return res.json().then(function(data) { | |
| 196 | - | var statusEl = document.getElementById('synckit-create-status'); | |
| 197 | - | if (statusEl && data.api_key) { | |
| 198 | - | statusEl.className = 'proj-synckit-create-status--reset'; | |
| 199 | - | statusEl.innerHTML = | |
| 200 | - | '<div class="proj-synckit-new-key">' + | |
| 201 | - | '<p class="proj-synckit-new-key-heading">Save this API key now: it cannot be shown again.</p>' + | |
| 202 | - | '<code class="proj-synckit-code--selectable">' + data.api_key + '</code>' + | |
| 203 | - | ' <button class="btn-small btn-secondary proj-synckit-btn-row" ' + | |
| 204 | - | 'onclick="navigator.clipboard.writeText(\'' + data.api_key + '\').then(function(){this.textContent=\'Copied\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))">Copy</button>' + | |
| 205 | - | '<button class="btn-small proj-synckit-btn-tight" ' + | |
| 206 | - | 'onclick="document.getElementById(\'tab-synckit\').click()">Done</button>' + | |
| 207 | - | '</div>'; | |
| 208 | - | } | |
| 209 | - | }); | |
| 210 | - | } else { | |
| 211 | - | return res.text().then(function(t) { | |
| 212 | - | var statusEl = document.getElementById('synckit-create-status'); | |
| 213 | - | statusEl.className = 'proj-synckit-create-status--error'; | |
| 214 | - | statusEl.textContent = t || 'Failed to create app.'; | |
| 215 | - | }); | |
| 216 | - | } | |
| 217 | - | }).catch(function() { | |
| 218 | - | var statusEl = document.getElementById('synckit-create-status'); | |
| 219 | - | statusEl.className = 'proj-synckit-create-status--error'; | |
| 220 | - | statusEl.textContent = 'Network error. Please check your connection and try again.'; | |
| 221 | - | }); | |
| 222 | - | }); | |
| 223 | - | })(); | |
| 92 | + | initSyncKitTab({ | |
| 93 | + | editInputClass: 'proj-synckit-slug-input input--sm w-120', | |
| 94 | + | editBtnClass: 'proj-synckit-btn-row', | |
| 95 | + | regenInline: true, | |
| 96 | + | create: { mode: 'project', projectId: '{{ project_id }}', errorClass: 'proj-synckit-create-status--error' }, | |
| 97 | + | }); | |
| 224 | 98 | </script> |
| @@ -109,180 +109,19 @@ | |||
| 109 | 109 | {% endif %} | |
| 110 | 110 | </div> | |
| 111 | 111 | ||
| 112 | - | <script> | |
| 113 | - | if (window.initSyncKitBilling) window.initSyncKitBilling(); | |
| 114 | - | function syncKitCopyKey(event, key) { | |
| 115 | - | navigator.clipboard.writeText(key).then(function() { | |
| 116 | - | var btn = event.target; | |
| 117 | - | var orig = btn.textContent; | |
| 118 | - | btn.textContent = 'Copied'; | |
| 119 | - | setTimeout(function() { btn.textContent = orig; }, 1500); | |
| 120 | - | }); | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | function syncKitRegenKey(appId) { | |
| 124 | - | if (!confirm('Regenerate API key? All existing clients using the current key will stop working.')) return; | |
| 125 | - | fetch('/api/sync/apps/' + appId + '/regenerate-key', { | |
| 126 | - | method: 'POST', | |
| 127 | - | credentials: 'same-origin', | |
| 128 | - | headers: csrfHeaders() | |
| 129 | - | }).then(function(res) { | |
| 130 | - | if (res.ok) { | |
| 131 | - | document.getElementById('tab-synckit').click(); | |
| 132 | - | } else { | |
| 133 | - | showToast('Failed to regenerate key.'); | |
| 134 | - | } | |
| 135 | - | }).catch(function() { | |
| 136 | - | showToast('Network error. Please check your connection and try again.'); | |
| 137 | - | }); | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | function syncKitDeleteApp(appId, name) { | |
| 141 | - | if (!confirm('Delete sync app "' + name + '"? This will remove all devices and sync data for this app.')) return; | |
| 142 | - | fetch('/api/sync/apps/' + appId, { | |
| 143 | - | method: 'DELETE', | |
| 144 | - | credentials: 'same-origin', | |
| 145 | - | headers: csrfHeaders() | |
| 146 | - | }).then(function(res) { | |
| 147 | - | if (res.ok) { | |
| 148 | - | document.getElementById('tab-synckit').click(); | |
| 149 | - | } else { | |
| 150 | - | showToast('Failed to delete app.'); | |
| 151 | - | } | |
| 152 | - | }).catch(function() { | |
| 153 | - | showToast('Network error. Please check your connection and try again.'); | |
| 154 | - | }); | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | function syncKitShowSlugForm(appId, currentSlug) { | |
| 158 | - | var cell = document.getElementById('synckit-slug-cell-' + appId); | |
| 159 | - | if (!cell) return; | |
| 160 | - | cell.innerHTML = ''; | |
| 161 | - | var input = document.createElement('input'); | |
| 162 | - | input.type = 'text'; | |
| 163 | - | input.value = currentSlug; | |
| 164 | - | input.placeholder = 'e.g. goingson'; | |
| 165 | - | input.className = 'user-synckit-edit-input input--sm w-120'; | |
| 166 | - | cell.appendChild(input); | |
| 167 | - | cell.appendChild(document.createTextNode(' ')); | |
| 168 | - | var saveBtn = document.createElement('button'); | |
| 169 | - | saveBtn.className = 'btn-small user-synckit-edit-btn'; | |
| 170 | - | saveBtn.textContent = 'Save'; | |
| 171 | - | saveBtn.addEventListener('click', function() { | |
| 172 | - | var slug = input.value.trim(); | |
| 173 | - | if (!slug) return; | |
| 174 | - | fetch('/api/sync/apps/' + appId + '/slug', { | |
| 175 | - | method: 'PUT', | |
| 176 | - | credentials: 'same-origin', | |
| 177 | - | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 178 | - | body: JSON.stringify({ slug: slug }) | |
| 179 | - | }).then(function(res) { | |
| 180 | - | if (res.ok) { | |
| 181 | - | document.getElementById('tab-synckit').click(); | |
| 182 | - | showToast('Update slug set to "' + slug + '".'); | |
| 183 | - | } else { | |
| 184 | - | res.text().then(function(t) { showToast(t || 'Failed to set slug.'); }); | |
| 185 | - | } | |
| 186 | - | }).catch(function() { | |
| 187 | - | showToast('Network error. Please check your connection and try again.'); | |
| 188 | - | }); | |
| 189 | - | }); | |
| 190 | - | cell.appendChild(saveBtn); | |
| 191 | - | cell.appendChild(document.createTextNode(' ')); | |
| 192 | - | var cancelBtn = document.createElement('button'); | |
| 193 | - | cancelBtn.className = 'btn-small secondary user-synckit-edit-btn'; | |
| 194 | - | cancelBtn.textContent = 'Cancel'; | |
| 195 | - | cancelBtn.addEventListener('click', function() { document.getElementById('tab-synckit').click(); }); | |
| 196 | - | cell.appendChild(cancelBtn); | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | function syncKitShowLinkForm(appId) { | |
| 200 | - | var cell = document.getElementById('synckit-link-cell-' + appId); | |
| 201 | - | if (!cell) return; | |
| 202 | - | cell.innerHTML = ''; | |
| 203 | - | var select = document.createElement('select'); | |
| 204 | - | select.id = 'synckit-link-select-' + appId; | |
| 205 | - | select.className = 'user-synckit-edit-select input--sm'; | |
| 206 | - | var noneOpt = document.createElement('option'); | |
| 207 | - | noneOpt.value = ''; | |
| 208 | - | noneOpt.textContent = 'None'; | |
| 209 | - | select.appendChild(noneOpt); | |
| 112 | + | <!-- Project options for the "Linked To" picker, HTML-escaped by Askama; the | |
| 113 | + | static JS clones these instead of having titles interpolated into a script. --> | |
| 114 | + | <template id="synckit-projects-options"> | |
| 210 | 115 | {% for project in projects %} | |
| 211 | - | var opt{{ loop.index }} = document.createElement('option'); | |
| 212 | - | opt{{ loop.index }}.value = '{{ project.id }}'; | |
| 213 | - | opt{{ loop.index }}.textContent = '{{ project.title }}'; | |
| 214 | - | select.appendChild(opt{{ loop.index }}); | |
| 116 | + | <option value="{{ project.id }}">{{ project.title }}</option> | |
| 215 | 117 | {% endfor %} | |
| 216 | - | cell.appendChild(select); | |
| 217 | - | cell.appendChild(document.createTextNode(' ')); | |
| 218 | - | var saveBtn = document.createElement('button'); | |
| 219 | - | saveBtn.className = 'btn-small user-synckit-edit-btn'; | |
| 220 | - | saveBtn.textContent = 'Save'; | |
| 221 | - | saveBtn.addEventListener('click', function() { syncKitSaveLink(appId); }); | |
| 222 | - | cell.appendChild(saveBtn); | |
| 223 | - | cell.appendChild(document.createTextNode(' ')); | |
| 224 | - | var cancelBtn = document.createElement('button'); | |
| 225 | - | cancelBtn.className = 'btn-small secondary user-synckit-edit-btn'; | |
| 226 | - | cancelBtn.textContent = 'Cancel'; | |
| 227 | - | cancelBtn.addEventListener('click', function() { document.getElementById('tab-synckit').click(); }); | |
| 228 | - | cell.appendChild(cancelBtn); | |
| 229 | - | } | |
| 118 | + | </template> | |
| 230 | 119 | ||
| 231 | - | function syncKitSaveLink(appId) { | |
| 232 | - | var sel = document.getElementById('synckit-link-select-' + appId); | |
| 233 | - | if (!sel) return; | |
| 234 | - | var projectId = sel.value; | |
| 235 | - | fetch('/api/sync/apps/' + appId + '/link', { | |
| 236 | - | method: 'PUT', | |
| 237 | - | credentials: 'same-origin', | |
| 238 | - | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 239 | - | body: JSON.stringify({ project_id: projectId || null, item_id: null }) | |
| 240 | - | }).then(function(res) { | |
| 241 | - | if (res.ok) { | |
| 242 | - | document.getElementById('tab-synckit').click(); | |
| 243 | - | } else { | |
| 244 | - | showToast('Failed to update link.'); | |
| 245 | - | } | |
| 246 | - | }).catch(function() { | |
| 247 | - | showToast('Network error. Please check your connection and try again.'); | |
| 248 | - | }); | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | (function() { | |
| 252 | - | var form = document.getElementById('synckit-create-form'); | |
| 253 | - | if (!form) return; | |
| 254 | - | form.addEventListener('submit', function(e) { | |
| 255 | - | e.preventDefault(); | |
| 256 | - | var nameInput = document.getElementById('synckit-app-name'); | |
| 257 | - | var name = nameInput.value.trim(); | |
| 258 | - | if (!name) return; | |
| 259 | - | ||
| 260 | - | var projectSelect = document.getElementById('synckit-app-project'); | |
| 261 | - | var projectId = projectSelect ? projectSelect.value : ''; | |
| 262 | - | ||
| 263 | - | var body = { name: name }; | |
| 264 | - | if (projectId) body.project_id = projectId; | |
| 265 | - | ||
| 266 | - | fetch('/api/sync/apps', { | |
| 267 | - | method: 'POST', | |
| 268 | - | credentials: 'same-origin', | |
| 269 | - | headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, | |
| 270 | - | body: JSON.stringify(body) | |
| 271 | - | }).then(function(res) { | |
| 272 | - | if (res.ok) { | |
| 273 | - | document.getElementById('tab-synckit').click(); | |
| 274 | - | } else { | |
| 275 | - | return res.text().then(function(t) { | |
| 276 | - | var statusEl = document.getElementById('synckit-create-status'); | |
| 277 | - | statusEl.className = 'user-synckit-create-error'; | |
| 278 | - | statusEl.textContent = t || 'Failed to create app.'; | |
| 279 | - | }); | |
| 280 | - | } | |
| 281 | - | }).catch(function() { | |
| 282 | - | var statusEl = document.getElementById('synckit-create-status'); | |
| 283 | - | statusEl.className = 'user-synckit-create-error'; | |
| 284 | - | statusEl.textContent = 'Network error. Please check your connection and try again.'; | |
| 285 | - | }); | |
| 286 | - | }); | |
| 287 | - | })(); | |
| 120 | + | <script> | |
| 121 | + | initSyncKitTab({ | |
| 122 | + | editInputClass: 'user-synckit-edit-input input--sm w-120', | |
| 123 | + | editBtnClass: 'user-synckit-edit-btn', | |
| 124 | + | regenInline: false, | |
| 125 | + | create: { mode: 'user', errorClass: 'user-synckit-create-error' }, | |
| 126 | + | }); | |
| 288 | 127 | </script> |