From d317f0e3146f37e846ec6cd817c8a75b93ebedb1 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 08:58:18 +1000 Subject: [PATCH] feat(settings): expandable Icon sets panel (view/upload/delete) Co-Authored-By: Claude Opus 4.8 --- public/api.js | 20 ++++++---- public/views/icon_sets_panel.js | 70 +++++++++++++++++++++++++++++++++ public/views/settings.js | 14 +++++++ 3 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 public/views/icon_sets_panel.js diff --git a/public/api.js b/public/api.js index 68deaa9..2921dfb 100644 --- a/public/api.js +++ b/public/api.js @@ -11,11 +11,14 @@ function token() { return localStorage.getItem(TOKEN_KEY) || ''; } async function call(method, path, body) { const headers = { 'Authorization': 'Bearer ' + token() }; - if (body !== undefined) headers['Content-Type'] = 'application/json'; + // FormData bodies: let the browser set the multipart/form-data boundary + // automatically — do NOT set Content-Type or JSON.stringify. + const isFormData = body instanceof FormData; + if (body !== undefined && !isFormData) headers['Content-Type'] = 'application/json'; const res = await fetch(path, { method, headers, - body: body === undefined ? undefined : JSON.stringify(body) + body: body === undefined ? undefined : (isFormData ? body : JSON.stringify(body)) }); if (res.status === 401) { await promptForToken(); return call(method, path, body); } if (res.status === 204) return null; @@ -61,11 +64,14 @@ function promptForToken() { } export const api = { - get: (p) => call('GET', p), - post: (p, body) => call('POST', p, body ?? {}), - put: (p, body) => call('PUT', p, body ?? {}), - patch: (p, body) => call('PATCH', p, body ?? {}), - del: (p) => call('DELETE', p), + get: (p) => call('GET', p), + post: (p, body) => call('POST', p, body ?? {}), + put: (p, body) => call('PUT', p, body ?? {}), + patch: (p, body) => call('PATCH', p, body ?? {}), + del: (p) => call('DELETE', p), + // POST a FormData body (multipart/form-data). Content-Type is omitted so + // the browser appends the correct multipart boundary automatically. + postForm: (p, fd) => call('POST', p, fd), setToken: (v) => localStorage.setItem(TOKEN_KEY, v), hasToken: () => !!token() }; diff --git a/public/views/icon_sets_panel.js b/public/views/icon_sets_panel.js new file mode 100644 index 0000000..7fd95f3 --- /dev/null +++ b/public/views/icon_sets_panel.js @@ -0,0 +1,70 @@ +// Icon sets management panel — list, upload, delete custom icon sets. +import { el, mount, clear } from '../dom.js'; +import { api } from '../api.js'; + +export function iconSetsPanel() { + const root = el('div', { class: 'icon-sets-panel' }); + + async function refresh() { + clear(root); + let list = []; + try { list = await api.get('/api/icon-sets'); } catch { + root.appendChild(el('div', { class: 'muted' }, 'unavailable')); + return; + } + for (const s of list) { + const grid = el('div', { class: 'ip-grid' }, + s.icons.map(f => + el('div', { class: 'ip-icon' }, + el('img', { src: `/api/icon-sets/${s.set}/${f}`, title: f }) + ) + ) + ); + const head = el('div', { class: 'isp-hd' }, + el('b', {}, s.set), + el('span', { class: 'muted' }, ` ${s.icons.length}`) + ); + if (!s.readonly) { + const del = el('button', { class: 'ghost' }, 'Delete'); + del.addEventListener('click', async () => { + await api.del('/api/icon-sets/' + s.set); + refresh(); + }); + head.appendChild(del); + } + root.appendChild(el('div', { class: 'isp-set' }, head, grid)); + } + root.appendChild(uploadForm(refresh)); + } + + refresh(); + return root; +} + +function uploadForm(onDone) { + const setI = el('input', { class: 'dv-edit-name', placeholder: 'new set name (a-z0-9-)' }); + const fileI = el('input', { type: 'file', accept: '.svg,.png,.jpg,.jpeg,.zip', multiple: true }); + const urlI = el('input', { class: 'dv-edit-name', placeholder: 'or ingest from URL (image or .zip)' }); + const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, ''); + const up = el('button', { class: 'dv-add' }, 'Upload'); + + up.addEventListener('click', async () => { + const set = setI.value.trim().toLowerCase(); + if (!/^[a-z0-9-]+$/.test(set)) { err.textContent = 'set name: a-z 0-9 - only'; return; } + if (!fileI.files.length && !urlI.value.trim()) { err.textContent = 'pick files or a URL'; return; } + const fd = new FormData(); + for (const f of fileI.files) fd.append('files', f); + if (urlI.value.trim()) fd.append('url', urlI.value.trim()); + up.textContent = 'Uploading…'; up.disabled = true; + try { + await api.postForm('/api/icon-sets/' + set, fd); + onDone(); + } catch { + err.textContent = 'upload failed'; + up.textContent = 'Upload'; + up.disabled = false; + } + }); + + return el('div', { class: 'isp-upload' }, setI, fileI, urlI, up, err); +} diff --git a/public/views/settings.js b/public/views/settings.js index 82876e6..41d0276 100644 --- a/public/views/settings.js +++ b/public/views/settings.js @@ -1,6 +1,7 @@ // #/settings — API tokens, agents, and a placeholder for Orthos Mode. import { el, mount } from '../dom.js'; import { api } from '../api.js'; +import { iconSetsPanel } from './icon_sets_panel.js'; function section(title, sub, bodyEl) { return el('div', { class: 'card settings-card' }, @@ -99,10 +100,23 @@ async function renderAgents(c) { export async function render(main) { const tokensBody = el('div', { class: 'settings-body' }); const agentsBody = el('div', { class: 'settings-body' }); + + // Icon sets — collapsible; panel is lazy-created once but hidden by default. + const isPanel = iconSetsPanel(); + isPanel.style.display = 'none'; + const isToggle = el('button', { class: 'ghost' }, '▸ Icon sets'); + isToggle.addEventListener('click', () => { + const open = isPanel.style.display !== 'none'; + isPanel.style.display = open ? 'none' : 'block'; + isToggle.textContent = (open ? '▸' : '▾') + ' Icon sets'; + }); + const iconSetsWrap = el('div', { class: 'settings-body' }, isToggle, isPanel); + mount(main, el('h1', { class: 'view-h1' }, '◆ Settings'), section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody), section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody), + section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap), section('Orthos Mode', 'Local-first answering — Orthos answers first, Claude escalates when needed.', el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).')) );