Skip to main content

max / makenotwork

synckit: extract dashboard-tab JS to a static file + close slug-in-onclick (ultra-fuzz --deep UX A+) UX A->A+ cold spot from the Run 3 SyncKit pass: - project_synckit slug "Set" button interpolated the slug into a JS string literal (onclick="...('{{ app.slug }}')") -- the named finding. It now flows through a data-slug attribute read as this.dataset.slug, matching the delete button's data-name and the user tab's already-safe pattern. Askama HTML-escapes the attribute, so a slug can't break out of the string. - Moved both tabs' inline <script> blocks into static/synckit-tabs.js, loaded once globally (base.html) like synckit-billing.js; each partial calls initSyncKitTab(config) with its tab-specific bits (edit-control classes, regen-inline behaviour, create-form mode). The user tab's link-picker options now come from a server-rendered <template> the JS clones, instead of project titles interpolated into the script; copy buttons are built via addEventListener, so the API key no longer lands in an inline onclick string. Behaviour preserved; UI to be eyeballed in-browser. - Drop now-unused SyncDeviceId import in db/synckit/keys.rs (left by the Phase 3 removal of update_device_cursor). Templates compile; node --check on the JS passes; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 20:38 UTC
Commit: bf7d7c6d3c5bb9b7e86f712aca9cdbaecda4a203
Parent: 0fb3b36
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>