From b03c3243d10beeb5c5b622d8d224de136dea03cc Mon Sep 17 00:00:00 2001 From: falkoro <39274208+falkoro@users.noreply.github.com> Date: Sun, 31 May 2026 16:35:29 +0200 Subject: [PATCH] feat: chip-based ticker editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the raw tickers textarea in Configure with a proper editor: existing tickers show as removable chips (× to delete), and an input + Add button (Enter or comma to add) appends new symbols — uppercased, sanitised, deduped, capped at 16. Much nicer than editing a comma blob. Frontend-only; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/actions.ts | 45 ++++++++++++++++++++++++++----------- public/actions.js | 55 +++++++++++++++++++++++++++++++++++---------- public/app.css | 12 ++++++++++ 3 files changed, 87 insertions(+), 25 deletions(-) diff --git a/frontend/actions.ts b/frontend/actions.ts index cdb120e..0e6d369 100644 --- a/frontend/actions.ts +++ b/frontend/actions.ts @@ -40,20 +40,12 @@ async function loadDashboardSettings(): Promise { applyDashboardSettings(await response.json() as DashboardSettings); } -function parseTickerText(text: string): string[] { - return text - .split(/[\n,]+/) - .map((item) => item.trim()) - .filter(Boolean); -} - function focusSettingsEditor(focus?: 'tickers'): void { const editor = document.getElementById('settingsEditor'); if (!editor) return; - const tickers = editor.querySelector('#settingsTickers'); + const tickers = editor.querySelector('#tickerInput'); if (focus === 'tickers' && tickers) { tickers.focus(); - tickers.select(); return; } editor.querySelector('input[name="machine"]')?.focus(); @@ -68,11 +60,38 @@ function openSettingsEditor(focus?: 'tickers'): void { overlay.className = 'links-editor-modal settings-editor-modal'; overlay.id = 'settingsEditor'; const panels = dashboardSettings.panels; - overlay.innerHTML = ``; + overlay.innerHTML = ``; document.body.appendChild(overlay); const form = overlay.querySelector('form')!; - const textarea = overlay.querySelector('#settingsTickers')!; - textarea.value = (dashboardSettings.tickers || []).join('\n'); + const chipsEl = overlay.querySelector('#tickerChips')!; + const input = overlay.querySelector('#tickerInput')!; + const editTickers = (dashboardSettings.tickers || []).slice(); + const renderChips = (): void => { + chipsEl.innerHTML = editTickers.length + ? editTickers.map((sym, i) => `${escapeHtml(sym)}`).join('') + : 'No tickers yet — add one below'; + }; + const addTicker = (): void => { + const sym = input.value.trim().toUpperCase().replace(/[^A-Z0-9.\-_=^]/g, '').slice(0, 24); + input.value = ''; + if (!sym) return; + if (editTickers.includes(sym)) { toast(`${sym} already added`); return; } + if (editTickers.length >= 16) { toast('Max 16 tickers'); return; } + editTickers.push(sym); + renderChips(); + input.focus(); + }; + renderChips(); + chipsEl.addEventListener('click', (event: MouseEvent) => { + const btn = (event.target as HTMLElement).closest('[data-remove-ticker]'); + if (!btn) return; + editTickers.splice(Number(btn.dataset.removeTicker), 1); + renderChips(); + }); + input.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ',') { event.preventDefault(); addTicker(); } + }); + overlay.querySelector('#addTickerBtn')?.addEventListener('click', addTicker); const close = (): void => overlay.remove(); overlay.querySelector('[data-close-settings]')?.addEventListener('click', close); overlay.addEventListener('mousedown', (event: MouseEvent) => { if (event.target === overlay) close(); }); @@ -80,7 +99,7 @@ function openSettingsEditor(focus?: 'tickers'): void { event.preventDefault(); const data = new FormData(form); saveDashboardSettings({ - tickers: parseTickerText(textarea.value), + tickers: editTickers, panels: { machine: data.has('machine'), machineSensors: data.has('machineSensors'), diff --git a/public/actions.js b/public/actions.js index f5ba5d0..e79b520 100644 --- a/public/actions.js +++ b/public/actions.js @@ -40,20 +40,13 @@ async function loadDashboardSettings() { return; applyDashboardSettings(await response.json()); } -function parseTickerText(text) { - return text - .split(/[\n,]+/) - .map((item) => item.trim()) - .filter(Boolean); -} function focusSettingsEditor(focus) { const editor = document.getElementById('settingsEditor'); if (!editor) return; - const tickers = editor.querySelector('#settingsTickers'); + const tickers = editor.querySelector('#tickerInput'); if (focus === 'tickers' && tickers) { tickers.focus(); - tickers.select(); return; } editor.querySelector('input[name="machine"]')?.focus(); @@ -67,11 +60,49 @@ function openSettingsEditor(focus) { overlay.className = 'links-editor-modal settings-editor-modal'; overlay.id = 'settingsEditor'; const panels = dashboardSettings.panels; - overlay.innerHTML = ``; + overlay.innerHTML = ``; document.body.appendChild(overlay); const form = overlay.querySelector('form'); - const textarea = overlay.querySelector('#settingsTickers'); - textarea.value = (dashboardSettings.tickers || []).join('\n'); + const chipsEl = overlay.querySelector('#tickerChips'); + const input = overlay.querySelector('#tickerInput'); + const editTickers = (dashboardSettings.tickers || []).slice(); + const renderChips = () => { + chipsEl.innerHTML = editTickers.length + ? editTickers.map((sym, i) => `${escapeHtml(sym)}`).join('') + : 'No tickers yet — add one below'; + }; + const addTicker = () => { + const sym = input.value.trim().toUpperCase().replace(/[^A-Z0-9.\-_=^]/g, '').slice(0, 24); + input.value = ''; + if (!sym) + return; + if (editTickers.includes(sym)) { + toast(`${sym} already added`); + return; + } + if (editTickers.length >= 16) { + toast('Max 16 tickers'); + return; + } + editTickers.push(sym); + renderChips(); + input.focus(); + }; + renderChips(); + chipsEl.addEventListener('click', (event) => { + const btn = event.target.closest('[data-remove-ticker]'); + if (!btn) + return; + editTickers.splice(Number(btn.dataset.removeTicker), 1); + renderChips(); + }); + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + addTicker(); + } + }); + overlay.querySelector('#addTickerBtn')?.addEventListener('click', addTicker); const close = () => overlay.remove(); overlay.querySelector('[data-close-settings]')?.addEventListener('click', close); overlay.addEventListener('mousedown', (event) => { if (event.target === overlay) @@ -80,7 +111,7 @@ function openSettingsEditor(focus) { event.preventDefault(); const data = new FormData(form); saveDashboardSettings({ - tickers: parseTickerText(textarea.value), + tickers: editTickers, panels: { machine: data.has('machine'), machineSensors: data.has('machineSensors'), diff --git a/public/app.css b/public/app.css index 0c7fad0..4bcb977 100644 --- a/public/app.css +++ b/public/app.css @@ -245,6 +245,18 @@ body.preview-fullscreen-open{overflow:hidden} .settings-grid label{display:flex;align-items:center;gap:8px;margin:0;color:var(--text);font-size:13px} .settings-grid .settings-subsetting{color:var(--muted);padding-left:18px} .settings-grid input{width:auto;min-height:0} +.ticker-editor{margin:6px 0 4px} +.ticker-chips{display:flex;flex-wrap:wrap;gap:6px;min-height:30px;padding:6px;border:1px solid rgba(255,255,255,.1);border-radius:8px;background:rgba(7,16,23,.5)} +.ticker-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-variant-numeric:tabular-nums;background:rgba(139,246,255,.1);border:1px solid rgba(139,246,255,.22);color:#c9fff3;border-radius:999px;padding:2px 4px 2px 9px} +.ticker-chip button{border:0;background:rgba(255,255,255,.06);color:var(--muted);border-radius:999px;width:17px;height:17px;line-height:15px;padding:0;cursor:pointer;font-size:13px} +.ticker-chip button:hover{background:rgba(255,106,122,.25);color:#ffd0d6} +.ticker-empty-chip{font-size:12px;align-self:center;padding:2px 4px} +.ticker-add{display:flex;gap:7px;margin-top:7px} +.ticker-add input{flex:1;min-width:0;text-transform:uppercase} +.ticker-add input::placeholder{text-transform:none} +.ticker-add button{flex:0 0 auto;display:inline-flex;align-items:center;gap:4px;padding:6px 12px} +.ticker-add-plus{font-size:15px;line-height:1} +.ticker-hint{font-size:11px;margin:5px 0 0} /* Button legend */ .legend{margin:0 0 8px;border:1px solid rgba(139,246,255,.18);border-radius:8px;background:#0b121a;font-size:12.5px} .legend>summary{cursor:pointer;padding:8px 10px;color:var(--muted);font-weight:600;list-style:none}