// — manage the config store (AGENT.md §1.3): forge hosts + // tokens, project directories to scan, and the git commit identity. Replaces // editing .env for domain config. // // A self-contained control (§1.1): shadow DOM, fetches its own data, posts to the // /api/config/* endpoints, and reports results via `toast` events. Tokens are // write-only from here — the server never returns them (only whether one is set). class SettingsPanel extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.#renderShell(); this.#loadForges(); this.#loadDirs(); this.#loadIdentity(); } // --- forges --------------------------------------------------------------- async #loadForges() { const box = this.shadowRoot.getElementById('forges'); try { const forges = await this.#json('GET', '/api/config/forges'); if (!forges || forges.length === 0) { box.innerHTML = `

No forges configured.

`; return; } box.replaceChildren(...forges.map((f) => { const row = document.createElement('div'); row.className = 'row'; row.innerHTML = `
${this.#esc(f.name || f.kind)} ${this.#esc(f.baseUrl)} ${f.hasToken ? 'token set' : 'no token'}
`; const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Remove'; del.onclick = () => this.#delete(`/api/config/forges/${f.id}`, 'Forge removed', () => this.#loadForges()); row.appendChild(del); return row; })); } catch (err) { box.innerHTML = `

${this.#esc(err.message)}

`; } } async #addForge() { const name = this.shadowRoot.getElementById('f-name').value.trim(); const baseUrl = this.shadowRoot.getElementById('f-url').value.trim(); const token = this.shadowRoot.getElementById('f-token').value; if (!baseUrl) { this.#toast('A base URL is required', 'error'); return; } try { await this.#json('POST', '/api/config/forges', { name, kind: 'gitea', baseUrl, token }); this.shadowRoot.getElementById('f-name').value = ''; this.shadowRoot.getElementById('f-url').value = ''; this.shadowRoot.getElementById('f-token').value = ''; this.#toast('Forge added', 'success'); this.#loadForges(); } catch (err) { this.#toast(`Add forge failed: ${err.message}`, 'error'); } } // --- project directories -------------------------------------------------- async #loadDirs() { const box = this.shadowRoot.getElementById('dirs'); try { const dirs = await this.#json('GET', '/api/config/project-dirs'); if (!dirs || dirs.length === 0) { box.innerHTML = `

No project directories configured.

`; return; } box.replaceChildren(...dirs.map((d) => { const row = document.createElement('div'); row.className = 'row'; row.innerHTML = ` `; row.querySelector('input').onchange = (e) => this.#put(`/api/config/project-dirs/${d.id}`, { enabled: e.target.checked }, e.target.checked ? 'Directory enabled' : 'Directory disabled'); const del = document.createElement('button'); del.className = 'danger'; del.textContent = 'Remove'; del.onclick = () => this.#delete(`/api/config/project-dirs/${d.id}`, 'Directory removed', () => this.#loadDirs()); row.appendChild(del); return row; })); } catch (err) { box.innerHTML = `

${this.#esc(err.message)}

`; } } async #addDir() { const path = this.shadowRoot.getElementById('d-path').value.trim(); if (!path) { this.#toast('A path is required', 'error'); return; } try { await this.#json('POST', '/api/config/project-dirs', { path }); this.shadowRoot.getElementById('d-path').value = ''; this.#toast('Directory added', 'success'); this.#loadDirs(); } catch (err) { this.#toast(`Add directory failed: ${err.message}`, 'error'); } } // --- git identity --------------------------------------------------------- async #loadIdentity() { try { const id = await this.#json('GET', '/api/config/identity'); this.shadowRoot.getElementById('i-name').value = id.name || ''; this.shadowRoot.getElementById('i-email').value = id.email || ''; } catch { /* leave blank */ } } async #saveIdentity() { const name = this.shadowRoot.getElementById('i-name').value.trim(); const email = this.shadowRoot.getElementById('i-email').value.trim(); try { await this.#put('/api/config/identity', { name, email }, 'Identity saved'); } catch (err) { this.#toast(`Save failed: ${err.message}`, 'error'); } } // --- helpers -------------------------------------------------------------- async #json(method, url, body) { const opts = { method }; if (body !== undefined) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); } const res = await fetch(url, opts); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } async #put(url, body, okMsg) { await this.#json('PUT', url, body); if (okMsg) this.#toast(okMsg, 'success'); } async #delete(url, okMsg, after) { if (!window.confirm('Remove this entry?')) return; try { await this.#json('DELETE', url); this.#toast(okMsg, 'success'); after?.(); } catch (err) { this.#toast(`Remove failed: ${err.message}`, 'error'); } } #toast(message, kind) { document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } })); } #renderShell() { this.shadowRoot.innerHTML = `

Forges

Hosting servers (Gitea/Forgejo) and their access tokens. Tokens are stored in the private config database and never shown again.

Loading…

Project directories

Directories to scan for repositories. Paths are inside the container — they must be under a mounted directory (e.g. under /repos).

Loading…

Git identity

Author used for commits the app makes.

`; this.shadowRoot.getElementById('f-add').onclick = () => this.#addForge(); this.shadowRoot.getElementById('d-add').onclick = () => this.#addDir(); this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity(); } #esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } } customElements.define('settings-panel', SettingsPanel);