settings.html (19897B)
1 {% extends "base.html" %} 2 3 {% block title %}Settings - StenoDojo{% endblock %} 4 5 {% block content %} 6 <div id="settings-container" class="settings-page"> 7 <h1 class="page-title">Settings</h1> 8 9 <div class="settings-section"> 10 <h2 class="section-title">Dictionaries</h2> 11 <p class="setting-help" style="margin-bottom: 1rem;">Select your Plover config directory — replaces all current dictionaries with the ones from plover.cfg.</p> 12 13 <div class="dict-upload-row"> 14 <div class="dict-actions"> 15 <label class="btn btn--primary dict-upload-btn"> 16 {% if dictionaries %}Swap Config Directory{% else %}Select Plover Config Directory{% endif %} 17 <input type="file" id="folder-upload" webkitdirectory hidden> 18 </label> 19 {% if dictionaries %} 20 <button id="clear-dicts-btn" class="btn btn--danger">Clear All Dictionaries</button> 21 {% endif %} 22 </div> 23 </div> 24 25 <div id="upload-progress" class="upload-progress" style="display: none;"> 26 <div class="spinner" style="width: 20px; height: 20px; border-width: 2px;"></div> 27 <span id="upload-progress-text">Uploading...</span> 28 </div> 29 30 <div id="dict-list" class="dict-list"> 31 {% if dictionaries %} 32 {% for d in dictionaries %} 33 <div class="dict-item" data-id="{{ d.id }}" draggable="false"> 34 <span class="dict-item__handle" aria-label="Drag to reorder"> 35 <svg width="10" height="16" viewBox="0 0 10 16" fill="currentColor"> 36 <circle cx="2" cy="2" r="1.5"/><circle cx="8" cy="2" r="1.5"/> 37 <circle cx="2" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/> 38 <circle cx="2" cy="14" r="1.5"/><circle cx="8" cy="14" r="1.5"/> 39 </svg> 40 </span> 41 <span class="dict-item__name">{{ d.display_name or d.filename }}</span> 42 <span class="dict-item__count">{{ d.entry_count }} entries</span> 43 <label class="dict-item__swap btn btn--ghost btn--small" data-id="{{ d.id }}">Swap<input type="file" accept=".json,.py" hidden></label> 44 <label class="dict-item__toggle"> 45 <input type="checkbox" class="dict-toggle-cb" data-id="{{ d.id }}" {{ 'checked' if d.enabled else '' }}> 46 </label> 47 <button class="dict-item__delete btn btn--ghost btn--small" data-id="{{ d.id }}">✕</button> 48 </div> 49 {% endfor %} 50 {% else %} 51 <div class="dict-empty">No dictionaries configured. Select your Plover config directory to get started.</div> 52 {% endif %} 53 </div> 54 </div> 55 56 <div class="settings-section"> 57 <h2 class="section-title">Practice</h2> 58 <div class="setting-row"> 59 <label class="setting-label" for="words-per-session">Words Per Session</label> 60 <div class="setting-input-group"> 61 <input type="number" id="words-per-session" class="setting-input setting-input--short" value="{{ settings.words_per_session }}" min="10" max="200"> 62 <button id="save-words-count" class="btn btn--primary btn--small">Save</button> 63 </div> 64 </div> 65 </div> 66 67 <div class="settings-section settings-section--danger"> 68 <h2 class="section-title">Data Management</h2> 69 <div class="setting-row"> 70 <div> 71 <div class="setting-label">Clear All Practice Data</div> 72 <p class="setting-help">This will permanently delete all session history, word stats, and progress. This cannot be undone.</p> 73 </div> 74 <button id="clear-data-btn" class="btn btn--danger">Clear All Data</button> 75 </div> 76 </div> 77 78 {% if current_user and not dev_mode %} 79 <div class="settings-section"> 80 <h2 class="section-title">Account</h2> 81 <div class="setting-row"> 82 <div> 83 <div class="setting-label">{{ current_user.email }}</div> 84 <p class="setting-help">Signed in as {{ current_user.name or current_user.email }}</p> 85 </div> 86 <a href="/logout" class="btn btn--ghost">Sign Out</a> 87 </div> 88 </div> 89 {% endif %} 90 91 <div id="settings-toast" class="settings-toast" style="display: none;"></div> 92 </div> 93 94 <script> 95 document.addEventListener('DOMContentLoaded', () => { 96 const toast = document.getElementById('settings-toast'); 97 const progressEl = document.getElementById('upload-progress'); 98 const progressText = document.getElementById('upload-progress-text'); 99 100 function showToast(msg, type) { 101 toast.textContent = msg; 102 toast.className = 'settings-toast settings-toast--' + type; 103 toast.style.display = 'block'; 104 if (typeof gsap !== 'undefined') { 105 gsap.fromTo(toast, { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.3, ease: 'power2.out' }); 106 setTimeout(() => { 107 gsap.to(toast, { y: 20, opacity: 0, duration: 0.25, onComplete: () => { toast.style.display = 'none'; } }); 108 }, 3000); 109 } else { 110 setTimeout(() => { toast.style.display = 'none'; }, 3000); 111 } 112 } 113 114 function showProgress(msg) { 115 progressText.textContent = msg; 116 progressEl.style.display = 'flex'; 117 } 118 function hideProgress() { 119 progressEl.style.display = 'none'; 120 } 121 122 function updateDictActions(hasDicts) { 123 const actions = document.querySelector('.dict-actions'); 124 const uploadLabel = actions.querySelector('.dict-upload-btn'); 125 uploadLabel.childNodes[0].textContent = hasDicts ? 'Swap Config Directory' : 'Select Plover Config Directory'; 126 let clearBtn = document.getElementById('clear-dicts-btn'); 127 if (hasDicts && !clearBtn) { 128 clearBtn = document.createElement('button'); 129 clearBtn.id = 'clear-dicts-btn'; 130 clearBtn.className = 'btn btn--danger'; 131 clearBtn.textContent = 'Clear All Dictionaries'; 132 actions.appendChild(clearBtn); 133 bindClearDicts(); 134 } else if (!hasDicts && clearBtn) { 135 clearBtn.remove(); 136 } 137 } 138 139 function renderDictList(dicts) { 140 const container = document.getElementById('dict-list'); 141 const hasDicts = dicts && dicts.length > 0; 142 updateDictActions(hasDicts); 143 if (!hasDicts) { 144 container.innerHTML = '<div class="dict-empty">No dictionaries configured. Select your Plover config directory to get started.</div>'; 145 return; 146 } 147 let html = ''; 148 dicts.forEach(d => { 149 const statusClass = d.uploaded ? '' : ' dict-item--missing'; 150 const statusLabel = d.uploaded ? d.entry_count + ' entries' : 'not uploaded'; 151 html += '<div class="dict-item' + statusClass + '" data-id="' + d.id + '" draggable="false">'; 152 html += '<span class="dict-item__handle" aria-label="Drag to reorder"><svg width="10" height="16" viewBox="0 0 10 16" fill="currentColor"><circle cx="2" cy="2" r="1.5"/><circle cx="8" cy="2" r="1.5"/><circle cx="2" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="2" cy="14" r="1.5"/><circle cx="8" cy="14" r="1.5"/></svg></span>'; 153 html += '<span class="dict-item__name">' + (d.display_name || d.filename) + '</span>'; 154 html += '<span class="dict-item__count">' + statusLabel + '</span>'; 155 html += '<label class="dict-item__swap btn btn--ghost btn--small" data-id="' + d.id + '">Swap<input type="file" accept=".json,.py" hidden></label>'; 156 html += '<label class="dict-item__toggle"><input type="checkbox" class="dict-toggle-cb" data-id="' + d.id + '"' + (d.enabled ? ' checked' : '') + '></label>'; 157 html += '<button class="dict-item__delete btn btn--ghost btn--small" data-id="' + d.id + '">✕</button>'; 158 html += '</div>'; 159 }); 160 container.innerHTML = html; 161 bindDictEvents(); 162 } 163 164 // ── Drag & Drop Reorder ── 165 function bindDragReorder() { 166 const list = document.getElementById('dict-list'); 167 let dragItem = null; 168 169 list.addEventListener('mousedown', e => { 170 const handle = e.target.closest('.dict-item__handle'); 171 const item = e.target.closest('.dict-item'); 172 if (item) item.draggable = !!handle; 173 }); 174 175 list.addEventListener('dragstart', e => { 176 const item = e.target.closest('.dict-item'); 177 if (!item) { e.preventDefault(); return; } 178 dragItem = item; 179 item.classList.add('dict-item--dragging'); 180 e.dataTransfer.effectAllowed = 'move'; 181 e.dataTransfer.setData('text/plain', item.dataset.id); 182 }); 183 184 list.addEventListener('dragend', e => { 185 if (dragItem) dragItem.classList.remove('dict-item--dragging'); 186 list.querySelectorAll('.dict-item').forEach(el => { 187 el.classList.remove('dict-item--drop-above', 'dict-item--drop-below'); 188 }); 189 dragItem = null; 190 }); 191 192 list.addEventListener('dragover', e => { 193 e.preventDefault(); 194 e.dataTransfer.dropEffect = 'move'; 195 const target = e.target.closest('.dict-item'); 196 if (!target || target === dragItem) return; 197 198 list.querySelectorAll('.dict-item').forEach(el => { 199 el.classList.remove('dict-item--drop-above', 'dict-item--drop-below'); 200 }); 201 202 const rect = target.getBoundingClientRect(); 203 const mid = rect.top + rect.height / 2; 204 if (e.clientY < mid) { 205 target.classList.add('dict-item--drop-above'); 206 } else { 207 target.classList.add('dict-item--drop-below'); 208 } 209 }); 210 211 list.addEventListener('dragleave', e => { 212 const target = e.target.closest('.dict-item'); 213 if (target) { 214 target.classList.remove('dict-item--drop-above', 'dict-item--drop-below'); 215 } 216 }); 217 218 list.addEventListener('drop', async e => { 219 e.preventDefault(); 220 const target = e.target.closest('.dict-item'); 221 if (!target || !dragItem || target === dragItem) return; 222 223 const rect = target.getBoundingClientRect(); 224 const before = e.clientY < rect.top + rect.height / 2; 225 226 if (before) { 227 list.insertBefore(dragItem, target); 228 } else { 229 list.insertBefore(dragItem, target.nextSibling); 230 } 231 232 dragItem.classList.remove('dict-item--dragging'); 233 list.querySelectorAll('.dict-item').forEach(el => { 234 el.classList.remove('dict-item--drop-above', 'dict-item--drop-below'); 235 }); 236 237 if (typeof gsap !== 'undefined') { 238 gsap.fromTo(dragItem, { opacity: 0.4, y: before ? -8 : 8 }, 239 { opacity: 1, y: 0, duration: 0.25, ease: 'power2.out' }); 240 } 241 242 const order = Array.from(list.querySelectorAll('.dict-item')) 243 .map(el => parseInt(el.dataset.id)); 244 245 try { 246 await fetch('/api/dictionaries/reorder', { 247 method: 'POST', 248 headers: {'Content-Type': 'application/json'}, 249 body: JSON.stringify({order: order}) 250 }); 251 } catch(err) { 252 showToast('Failed to save order.', 'error'); 253 } 254 255 dragItem = null; 256 }); 257 } 258 bindDragReorder(); 259 260 function bindDictEvents() { 261 document.querySelectorAll('.dict-toggle-cb').forEach(cb => { 262 cb.addEventListener('change', async () => { 263 const id = cb.dataset.id; 264 try { 265 await fetch('/api/dictionaries/' + id + '/toggle', { 266 method: 'POST', 267 headers: {'Content-Type': 'application/json'}, 268 body: JSON.stringify({enabled: cb.checked}) 269 }); 270 showToast('Dictionary ' + (cb.checked ? 'enabled' : 'disabled') + '.', 'success'); 271 } catch(e) { 272 showToast('Failed to toggle dictionary.', 'error'); 273 } 274 }); 275 }); 276 277 document.querySelectorAll('.dict-item__swap input[type="file"]').forEach(input => { 278 input.addEventListener('change', async () => { 279 const file = input.files[0]; 280 if (!file) return; 281 const id = input.closest('.dict-item__swap').dataset.id; 282 const item = input.closest('.dict-item'); 283 const form = new FormData(); 284 form.append('file', file); 285 try { 286 const r = await fetch('/api/dictionaries/' + id + '/replace', { method: 'POST', body: form }); 287 const d = await r.json(); 288 if (d.ok) { 289 item.querySelector('.dict-item__name').textContent = d.display_name || d.filename; 290 item.querySelector('.dict-item__count').textContent = d.entry_count + ' entries'; 291 item.classList.remove('dict-item--missing'); 292 showToast('Dictionary replaced.', 'success'); 293 } else { 294 showToast(d.error || 'Replace failed.', 'error'); 295 } 296 } catch(e) { 297 showToast('Failed to replace dictionary.', 'error'); 298 } 299 input.value = ''; 300 }); 301 }); 302 303 document.querySelectorAll('.dict-item__delete').forEach(btn => { 304 btn.addEventListener('click', async () => { 305 if (!confirm('Delete this dictionary?')) return; 306 const id = btn.dataset.id; 307 try { 308 await fetch('/api/dictionaries/' + id, { method: 'DELETE' }); 309 btn.closest('.dict-item').remove(); 310 showToast('Dictionary deleted.', 'success'); 311 } catch(e) { 312 showToast('Failed to delete.', 'error'); 313 } 314 }); 315 }); 316 } 317 bindDictEvents(); 318 319 function bindClearDicts() { 320 const btn = document.getElementById('clear-dicts-btn'); 321 if (!btn) return; 322 btn.addEventListener('click', async () => { 323 if (!confirm('Remove all dictionaries and uploaded files? This cannot be undone.')) return; 324 try { 325 const r = await fetch('/api/dictionaries/clear', { 326 method: 'POST', 327 headers: {'Content-Type': 'application/json'} 328 }); 329 const d = await r.json(); 330 if (d.ok) { 331 renderDictList([]); 332 showToast('All dictionaries cleared.', 'success'); 333 } 334 } catch(e) { 335 showToast('Failed to clear dictionaries.', 'error'); 336 } 337 }); 338 } 339 bindClearDicts(); 340 341 function parsePloverCfg(text) { 342 const match = text.match(/^\[System:\s*English Stenotype\]\s*$/m); 343 if (!match) return []; 344 const afterSection = text.slice(match.index + match[0].length); 345 const dictLine = afterSection.match(/^dictionaries\s*=\s*(.+)$/m); 346 if (!dictLine) return []; 347 try { 348 return JSON.parse(dictLine[1]); 349 } catch(e) { 350 return []; 351 } 352 } 353 354 // ── Folder upload with client-side filtering ── 355 document.getElementById('folder-upload').addEventListener('change', async (e) => { 356 const allFiles = e.target.files; 357 if (!allFiles || !allFiles.length) return; 358 359 let cfgFile = null; 360 let cfgPrefix = ''; 361 const filesByPath = {}; 362 363 for (let i = 0; i < allFiles.length; i++) { 364 const f = allFiles[i]; 365 const path = f.webkitRelativePath || f.name; 366 filesByPath[path] = f; 367 if (f.name === 'plover.cfg') { 368 cfgFile = f; 369 cfgPrefix = path.slice(0, -'plover.cfg'.length); 370 } 371 } 372 373 if (!cfgFile) { 374 showToast('No plover.cfg found in that directory.', 'error'); 375 e.target.value = ''; 376 return; 377 } 378 379 showProgress('Reading config...'); 380 381 const cfgText = await cfgFile.text(); 382 const entries = parsePloverCfg(cfgText); 383 384 if (!entries.length) { 385 hideProgress(); 386 showToast('No dictionaries found in plover.cfg.', 'error'); 387 e.target.value = ''; 388 return; 389 } 390 391 const form = new FormData(); 392 form.append('config', cfgFile, 'plover.cfg'); 393 394 let matched = 0; 395 for (const entry of entries) { 396 const dictPath = entry.path || ''; 397 if (!dictPath) continue; 398 399 let file = filesByPath[cfgPrefix + dictPath]; 400 if (!file) { 401 const basename = dictPath.split('/').pop().split('\\').pop(); 402 for (const [fpath, fobj] of Object.entries(filesByPath)) { 403 if (fobj.name === basename) { file = fobj; break; } 404 } 405 } 406 if (file) { 407 form.append('dicts', file, file.webkitRelativePath || file.name); 408 matched++; 409 } 410 } 411 412 showProgress('Uploading plover.cfg + ' + matched + ' dictionaries...'); 413 414 try { 415 const r = await fetch('/api/dictionaries/upload-folder', { method: 'POST', body: form }); 416 const d = await r.json(); 417 hideProgress(); 418 if (d.ok) { 419 renderDictList(d.dictionaries); 420 if (d.missing > 0) { 421 showToast(d.saved + ' dictionaries loaded, ' + d.missing + ' referenced files not found.', 'success'); 422 } else { 423 showToast('All ' + d.saved + ' dictionaries loaded!', 'success'); 424 } 425 } else { 426 showToast(d.error || 'Upload failed.', 'error'); 427 } 428 } catch(err) { 429 hideProgress(); 430 showToast('Failed to upload.', 'error'); 431 } 432 e.target.value = ''; 433 }); 434 435 document.getElementById('save-words-count').addEventListener('click', async () => { 436 const count = parseInt(document.getElementById('words-per-session').value); 437 if (isNaN(count) || count < 10 || count > 200) { 438 showToast('Must be between 10 and 200.', 'error'); 439 return; 440 } 441 try { 442 const r = await fetch('/api/settings', { 443 method: 'POST', 444 headers: {'Content-Type': 'application/json'}, 445 body: JSON.stringify({words_per_session: count}) 446 }); 447 const d = await r.json(); 448 if (d.ok) showToast('Words per session updated.', 'success'); 449 } catch(e) { 450 showToast('Failed to save.', 'error'); 451 } 452 }); 453 454 document.getElementById('clear-data-btn').addEventListener('click', async () => { 455 if (!confirm('Are you sure you want to delete ALL practice data? This cannot be undone.')) return; 456 try { 457 const r = await fetch('/api/data/clear', { 458 method: 'POST', 459 headers: {'Content-Type': 'application/json'} 460 }); 461 const d = await r.json(); 462 if (d.ok) showToast('All data cleared.', 'success'); 463 } catch(e) { 464 showToast('Failed to clear data.', 'error'); 465 } 466 }); 467 }); 468 </script> 469 {% endblock %}