Changeset 0.25.1 (#161)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
|
||||
// Single source of truth for constants + reusable rendering functions.
|
||||
// Loaded after ui-format.js, before ui-core.js.
|
||||
// Loaded in base.html before all components and surfaces.
|
||||
// Follows the renderPersonaForm() pattern: (container, options) → control object.
|
||||
// Designed for extension hooks: registries are open for .add(), primitives
|
||||
// accept options for customization, and return handles for programmatic control.
|
||||
@@ -8,6 +8,67 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §0 CORE UTILITIES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** HTML-escape a string for safe insertion into innerHTML and attributes. */
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §0b COMPONENT LIFECYCLE
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Create a component registry (factory namespace with instance tracking).
|
||||
*
|
||||
* @param {string} name - Component name (for debug)
|
||||
* @returns {{ primary, _instances, _register, _unregister, get }}
|
||||
*/
|
||||
function createComponentRegistry(name) {
|
||||
return {
|
||||
primary: null,
|
||||
_instances: new Map(),
|
||||
_register(id, instance) { this._instances.set(id, instance); },
|
||||
_unregister(id) {
|
||||
this._instances.delete(id);
|
||||
if (this.primary && this.primary.id === id) this.primary = null;
|
||||
},
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mix lifecycle methods into a component instance.
|
||||
* Adds _listeners array, _on() for tracked event binding, and destroy()
|
||||
* for automatic cleanup + registry removal.
|
||||
* Components needing extra teardown define _cleanup() which is called first.
|
||||
*
|
||||
* @param {Object} instance - The instance object (must have .id)
|
||||
* @param {Object} registry - The component registry (from createComponentRegistry)
|
||||
* @returns {Object} The instance, mutated with lifecycle methods
|
||||
*/
|
||||
function componentMixin(instance, registry) {
|
||||
instance._listeners = [];
|
||||
instance._on = function(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
};
|
||||
instance.destroy = function() {
|
||||
if (typeof this._cleanup === 'function') this._cleanup();
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
registry._unregister(this.id);
|
||||
};
|
||||
return instance;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §1 PROVIDER REGISTRY
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -933,3 +994,98 @@ function updateTabArrows(tabs) {
|
||||
left.classList.toggle('visible', !atStart);
|
||||
right.classList.toggle('visible', !atEnd);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §9 THEME
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const Theme = {
|
||||
_key: 'switchboard_theme',
|
||||
|
||||
init() {
|
||||
this.set(localStorage.getItem(this._key) || 'system');
|
||||
},
|
||||
|
||||
set(mode) {
|
||||
localStorage.setItem(this._key, mode);
|
||||
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', mode);
|
||||
},
|
||||
|
||||
get() {
|
||||
return localStorage.getItem(this._key) || 'system';
|
||||
},
|
||||
|
||||
resolved() {
|
||||
const mode = this.get();
|
||||
if (mode !== 'system') return mode;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
},
|
||||
|
||||
renderToggle(containerId) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
const current = this.get();
|
||||
const options = [
|
||||
{ id: 'light', label: 'Light', icon: '☀' },
|
||||
{ id: 'dark', label: 'Dark', icon: '☾' },
|
||||
{ id: 'system',label: 'System',icon: '⊞' },
|
||||
];
|
||||
el.innerHTML = '<div class="toggle-group">' + options.map(o =>
|
||||
`<button class="toggle-btn${current === o.id ? ' active' : ''}" data-theme="${o.id}" title="${o.label}">${o.icon}</button>`
|
||||
).join('') + '</div>';
|
||||
el.querySelectorAll('.toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.set(btn.dataset.theme);
|
||||
this.renderToggle(containerId);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// §10 AVATAR UPLOAD
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Mount an avatar upload component into a container.
|
||||
*
|
||||
* @param {HTMLElement} containerEl
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.current] - Current avatar URL
|
||||
* @param {number} [opts.size=80] - Avatar size in px
|
||||
* @param {Function} [opts.onUpload] - Called with (file, dataURL)
|
||||
* @returns {{ setImage }}
|
||||
*/
|
||||
function mountAvatarUpload(containerEl, opts = {}) {
|
||||
const size = opts.size || 80;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.style.display = 'none';
|
||||
|
||||
containerEl.className = 'avatar-upload';
|
||||
containerEl.style.width = size + 'px';
|
||||
containerEl.style.height = size + 'px';
|
||||
|
||||
function render(src) {
|
||||
if (src) containerEl.innerHTML = `<img src="${esc(src)}" alt="Avatar">`;
|
||||
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
|
||||
containerEl.appendChild(input);
|
||||
}
|
||||
|
||||
render(opts.current || null);
|
||||
containerEl.addEventListener('click', () => input.click());
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
render(ev.target.result);
|
||||
if (opts.onUpload) opts.onUpload(file, ev.target.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
return { setImage: render };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user