stenodojo

stenodojo
git clone git@git.zachrice.app:repos/stenodojo.git
Log | Files | Refs

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 }}">&#10005;</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 + '">&#10005;</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 %}