diff options
| author | Jakob L. Kreuze <zerodaysfordays@sdf.org> | 2025-11-26 15:29:13 -0500 |
|---|---|---|
| committer | Jakob L. Kreuze <zerodaysfordays@sdf.org> | 2025-11-26 15:29:13 -0500 |
| commit | 5dd9489868d24140d4006cc80edf3a3e16de8290 (patch) | |
| tree | 3ef03b90cc3470195bb6accf9ffee4923040d721 | |
| parent | c6aac96f1c5eb0ed1ee7a21a1e6f43f221737d58 (diff) | |
card editor: first releasable version
| -rw-r--r-- | card-editor.html | 226 | ||||
| -rw-r--r-- | card-editor.js | 185 | ||||
| -rw-r--r-- | srs-anywhere.html | 2 | ||||
| -rw-r--r-- | style.css | 18 |
4 files changed, 217 insertions, 214 deletions
diff --git a/card-editor.html b/card-editor.html index b838b59..d4fca8a 100644 --- a/card-editor.html +++ b/card-editor.html @@ -1,215 +1,17 @@ <!DOCTYPE html> <html lang="en"> -<head> -<meta charset="UTF-8"> -<title>JSON List Editor</title> -<style> - body{font-family:Arial,Helvetica,sans-serif;margin:20px;} - h1{margin-bottom:5px;} - textarea{width:100%;height:120px;font-family:monospace;} - button{margin:5px 0;} - table{border-collapse:collapse;width:100%;margin-top:10px;} - th,td{border:1px solid #aaa;padding:4px 6px;text-align:left;} - th{background:#f0f0f0;} - input[type=text]{width:100%;box-sizing:border-box;} - .hidden{display:none;} - .del-btn{background:#e74c3c;color:white;border:none;padding:4px 6px;cursor:pointer;} -</style> -</head> -<body> - -<h1>JSON List Editor</h1> - -<p>Paste or load a JSON array of objects below and click <strong>Load</strong>.</p> - -<textarea id="jsonInput" placeholder='[{"id":1,"name":"Alice","age":30},{"id":2,"name":"Bob","_secret":"xyz"}]'></textarea><br> -<button id="loadBtn">Load</button> -<button id="addBtn">New card</button> -<button id="saveBtn">Download JSON</button> -<input type="file" id="fileInput" accept=".json" style="display:none;"> - -<div id="tableContainer"></div> - -<script> -/* --------------- Global State --------------- */ -let data = []; // array of objects -let fieldKeys = []; // columns that will be shown (no leading "_") - -/* --------------- Load & Parse JSON --------------- */ -function loadJSON(text) { - try { - const parsed = JSON.parse(text); - if (!Array.isArray(parsed)) throw new Error('JSON must be an array of objects'); - data = parsed.map(obj => ({...obj})); // clone to avoid accidental mutations - computeFieldKeys(); - renderTable(); - } catch (e) { - alert('Error parsing JSON: ' + e.message); - } -} - -function computeFieldKeys() { - const keys = new Set(); - data.forEach(obj => { - Object.keys(obj).forEach(k => { - if (!k.startsWith('_')) keys.add(k); - }); - }); - fieldKeys = Array.from(keys); -} - -/* --------------- Render Table --------------- */ -function renderTable() { - const container = document.getElementById('tableContainer'); - container.innerHTML = ''; // clear previous - - if (!data.length) return; - - const table = document.createElement('table'); - const thead = document.createElement('thead'); - const headerRow = document.createElement('tr'); - - // Build header - fieldKeys.forEach(k => { - const th = document.createElement('th'); - th.textContent = k; - headerRow.appendChild(th); - }); - thead.appendChild(headerRow); - table.appendChild(thead); - - // Extra header for Delete button - const delTh = document.createElement('th'); - delTh.textContent = 'Delete'; - headerRow.appendChild(delTh); - - // Build body - const tbody = document.createElement('tbody'); - data.forEach((obj, rowIndex) => { - const tr = document.createElement('tr'); - fieldKeys.forEach(k => { - const td = document.createElement('td'); - const input = document.createElement('textarea'); - input.rows = 2; - // const input = document.createElement('input'); - // input.type = 'text'; - input.value = obj[k] !== undefined ? obj[k] : ''; - input.dataset.row = rowIndex; - input.dataset.key = k; - input.addEventListener('input', onFieldChange); - td.appendChild(input); - tr.appendChild(td); - }); - tbody.appendChild(tr); - - // Delete button column - const delTd = document.createElement('td'); - const delBtn = document.createElement('button'); - delBtn.textContent = 'x'; - delBtn.className = 'del-btn'; - delBtn.dataset.row = rowIndex; - delBtn.addEventListener('click', () => onDeleteRow(rowIndex)); - delTd.appendChild(delBtn); - tr.appendChild(delTd); - }); - - table.appendChild(tbody); - container.appendChild(table); -} - -function onFieldChange(e) { - const row = e.target.dataset.row; - const key = e.target.dataset.key; - const value = e.target.value; - data[row][key] = value; -} - -/** - * Remove all keys from an object whose value is the empty string. - * - * @param {Object} obj - The source object to clean. - * @param {boolean} [inPlace=false] - If true the original object is mutated. - * @returns {Object} A new object (or the original if inPlace is true) without empty‑string values. - * - * @example - * const data = { a: 'foo', b: '', c: 'bar', d: '' }; - * const cleaned = removeEmptyStrings(data); - * // cleaned === { a: 'foo', c: 'bar' } - */ -function removeEmptyStrings(obj, inPlace = false) { - if (typeof obj !== 'object' || obj === null) { - throw new TypeError('Expected an object'); - } - - // If we’re mutating, work directly on the original - const target = inPlace ? obj : {}; - - for (const key in obj) { - if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; - - const value = obj[key]; - - if (value === '') { - if (!inPlace) continue; // skip adding it to the new object - delete target[key]; // remove from the original - } else { - target[key] = value; // copy non‑empty value - } - } - - return target; -} - -function onDeleteRow(idx) { - data.splice(idx, 1); - renderTable(); -} - -/* --------------- File I/O --------------- */ -document.getElementById('loadBtn').addEventListener('click', () => { - const text = document.getElementById('jsonInput').value; - loadJSON(text); -}); - -document.getElementById('saveBtn').addEventListener('click', () => { - if (!data.length) return; - data.forEach(removeEmptyStrings); - const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'}); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'edited.json'; - a.click(); - URL.revokeObjectURL(url); -}); - -/* --------------- Optional: Drag‑and‑Drop or File Input --------------- */ -document.getElementById('fileInput').addEventListener('change', evt => { - const file = evt.target.files[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = e => loadJSON(e.target.result); - reader.readAsText(file); -}); - -/* --------------- Optional: Load file via button --------------- */ -const fileBtn = document.createElement('button'); -fileBtn.textContent = 'Load from File'; -fileBtn.addEventListener('click', () => document.getElementById('fileInput').click()); -document.body.insertBefore(fileBtn, document.getElementById('tableContainer')); - -/* --------------- Add New Object --------------- */ -document.getElementById('addBtn').addEventListener('click', () => { - if (!fieldKeys.length) { - alert('Load a JSON file first, then you can add a new object.'); - return; - } - const newObj = {}; - fieldKeys.forEach(k => newObj[k] = ''); - newObj.id = crypto.randomUUID(); - data.push(newObj); - renderTable(); -}); -</script> -</body> + <head> + <meta charset="UTF-8"> + <title>SRS Anywhere Card Editor</title> + <link rel="stylesheet" href="style.css"> + </head> + <body> + <div class="status-section"> + <button id="save-button">Download JSON</button> + <input type="file" id="cards-input" accept=".json" /> + </div> + <h1>SRS Anywhere Card Editor</h1> + <div id="table-container"></div> + <script src="card-editor.js"></script> + </body> </html> diff --git a/card-editor.js b/card-editor.js new file mode 100644 index 0000000..b60185e --- /dev/null +++ b/card-editor.js @@ -0,0 +1,185 @@ +/* Copyright © 2025 Jakob L. Kreuze + + This file is part of SRS Anywhere. + + SRS Anywhere is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + SRS Anywhere is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public + License along with SRS Anywhere. If not, see <https://www.gnu.org/licenses/>. */ + +let data = []; +let fieldKeys = {}; + +function loadJSON(text) { + try { + const parsed = JSON.parse(text); + if (!Array.isArray(parsed)) throw new Error('JSON must be an array of objects'); + data = parsed.map(obj => ({...obj})); + computeFieldKeys(); + renderTable(); + } catch (e) { + alert('Error parsing JSON: ' + e.message); + } +} + +function partition(arr, key = 'type') { + return arr.reduce((acc, item) => { + const k = item[key] || 'default'; + const prop = String(k); + if (!acc[prop]) acc[prop] = []; + acc[prop].push(item); + return acc; + }, {}); +} + +function computeFieldKeys() { + const partitioned = partition(data); + Object.entries(partitioned).forEach(([type, cards]) => { + const keys = new Set(); + cards.forEach(card => { + Object.keys(card).forEach(k => { + if (!k.startsWith('_')) keys.add(k); + }); + }) + fieldKeys[type] = Array.from(keys); + }); +} + +function renderTable() { + const container = document.getElementById('table-container'); + container.innerHTML = ''; // clear previous + + if (!data.length) return; + + Object.entries(fieldKeys).forEach(([type, keys]) => { + const table = document.createElement('table'); + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + const addButton = document.createElement('button'); + + table.style = "margin-bottom: 32px;" + + // Build header + keys.forEach(k => { + const th = document.createElement('th'); + th.textContent = k; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + // Extra header for Delete button + const delTh = document.createElement('th'); + delTh.textContent = 'Delete'; + headerRow.appendChild(delTh); + + // Build body + const tbody = document.createElement('tbody'); + data.forEach((obj, rowIndex) => { + if ((obj.type || 'default') != type) { + return; + } + const tr = document.createElement('tr'); + keys.forEach(k => { + const td = document.createElement('td'); + const input = document.createElement('textarea'); + input.rows = 2; + input.value = obj[k] !== undefined ? obj[k] : ''; + input.dataset.row = rowIndex; + input.dataset.key = k; + input.addEventListener('input', onFieldChange); + td.appendChild(input); + tr.appendChild(td); + }); + tbody.appendChild(tr); + + // Delete button column + const delTd = document.createElement('td'); + const delBtn = document.createElement('button'); + delBtn.textContent = 'x'; + delBtn.className = 'delete'; + delBtn.dataset.row = rowIndex; + delBtn.addEventListener('click', () => { + data.splice(idx, 1); + renderTable(); + }); + delTd.appendChild(delBtn); + tr.appendChild(delTd); + }); + + table.appendChild(tbody); + + addButton.innerHTML = '+'; + addButton.addEventListener('click', () => { + const newObj = {}; + keys.forEach(k => newObj[k] = ''); + newObj.id = crypto.randomUUID(); + data.push(newObj); + renderTable(); + }); + + container.appendChild(addButton); + container.appendChild(table); + }); +} + +function onFieldChange(e) { + const row = e.target.dataset.row; + const key = e.target.dataset.key; + const value = e.target.value; + data[row][key] = value; +} + +function removeEmptyStrings(obj, inPlace = false) { + if (typeof obj !== 'object' || obj === null) { + throw new TypeError('Expected an object'); + } + + // If we’re mutating, work directly on the original + const target = inPlace ? obj : {}; + + for (const key in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; + + const value = obj[key]; + + if (value === '') { + if (!inPlace) continue; // skip adding it to the new object + delete target[key]; // remove from the original + } else { + target[key] = value; // copy non‑empty value + } + } + + return target; +} + +window.addEventListener('load', function() { + document.getElementById('cards-input').addEventListener('change', evt => { + const file = evt.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = e => loadJSON(e.target.result); + reader.readAsText(file); + }); + + document.getElementById('save-button').addEventListener('click', () => { + if (!data.length) return; + data.forEach(removeEmptyStrings); + const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'edited.json'; + a.click(); + URL.revokeObjectURL(url); + }); +}) diff --git a/srs-anywhere.html b/srs-anywhere.html index 06101ce..879c6e8 100644 --- a/srs-anywhere.html +++ b/srs-anywhere.html @@ -6,7 +6,7 @@ <title>SRS Anywhere</title> <link rel="stylesheet" href="style.css"> </head> - <body> + <body style="display: flex;"> <div id="flashcard"> <div id="flashcard-front"></div> <div id="flashcard-back"></div> @@ -1,5 +1,4 @@ body { - display: flex; justify-content: center; align-items: center; height: 100vh; @@ -135,3 +134,20 @@ th, td { th { background-color: #f4f4f4; } + +.hidden { + display: none; +} + +button .delete { + background: #e74c3c; + color: white; + border: none; + padding: 4px 6px; + cursor: pointer; +} + +textarea { + width: 100%; + resize: vertical; +} |