feat(settings): expandable Icon sets panel (view/upload/delete)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,11 +11,14 @@ function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
|
|||||||
|
|
||||||
async function call(method, path, body) {
|
async function call(method, path, body) {
|
||||||
const headers = { 'Authorization': 'Bearer ' + token() };
|
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, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
headers,
|
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 === 401) { await promptForToken(); return call(method, path, body); }
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
@@ -66,6 +69,9 @@ export const api = {
|
|||||||
put: (p, body) => call('PUT', p, body ?? {}),
|
put: (p, body) => call('PUT', p, body ?? {}),
|
||||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||||
del: (p) => call('DELETE', p),
|
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),
|
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
|
||||||
hasToken: () => !!token()
|
hasToken: () => !!token()
|
||||||
};
|
};
|
||||||
|
|||||||
70
public/views/icon_sets_panel.js
Normal file
70
public/views/icon_sets_panel.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// #/settings — API tokens, agents, and a placeholder for Orthos Mode.
|
// #/settings — API tokens, agents, and a placeholder for Orthos Mode.
|
||||||
import { el, mount } from '../dom.js';
|
import { el, mount } from '../dom.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { iconSetsPanel } from './icon_sets_panel.js';
|
||||||
|
|
||||||
function section(title, sub, bodyEl) {
|
function section(title, sub, bodyEl) {
|
||||||
return el('div', { class: 'card settings-card' },
|
return el('div', { class: 'card settings-card' },
|
||||||
@@ -99,10 +100,23 @@ async function renderAgents(c) {
|
|||||||
export async function render(main) {
|
export async function render(main) {
|
||||||
const tokensBody = el('div', { class: 'settings-body' });
|
const tokensBody = el('div', { class: 'settings-body' });
|
||||||
const agentsBody = 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,
|
mount(main,
|
||||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
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('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('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.',
|
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).'))
|
el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).'))
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user