feat(settings): expandable Icon sets panel (view/upload/delete)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-09 08:58:18 +10:00
parent 2bf66ec570
commit d317f0e314
3 changed files with 97 additions and 7 deletions

View File

@@ -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()
};

View File

@@ -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);
}

View File

@@ -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).'))
);