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) {
|
||||
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()
|
||||
};
|
||||
|
||||
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.
|
||||
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).'))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user