Changeset 0.7.3 (#41)
This commit is contained in:
@@ -228,8 +228,19 @@ const API = {
|
||||
|
||||
// ── Profile & Settings ───────────────────
|
||||
|
||||
getProfile() { return this._get('/api/v1/profile'); },
|
||||
async getProfile() {
|
||||
const data = await this._get('/api/v1/profile');
|
||||
// Sync user object with profile data (including avatar)
|
||||
if (data && this.user) {
|
||||
this.user.avatar = data.avatar || null;
|
||||
this.user.display_name = data.display_name;
|
||||
this.saveTokens();
|
||||
}
|
||||
return data;
|
||||
},
|
||||
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
|
||||
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
|
||||
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
|
||||
changePassword(current, newPw) {
|
||||
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||||
},
|
||||
@@ -271,6 +282,8 @@ const API = {
|
||||
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
|
||||
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
|
||||
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
||||
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
|
||||
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
|
||||
|
||||
// ── User Presets ─────────────────────────
|
||||
listPresets() { return this._get('/api/v1/presets'); },
|
||||
|
||||
262
src/js/app.js
262
src/js/app.js
@@ -24,6 +24,7 @@ const App = {
|
||||
|
||||
async function init() {
|
||||
console.log('🔀 Chat Switchboard initializing...');
|
||||
initBranding(); // Apply branding before splash is visible
|
||||
API.loadTokens();
|
||||
|
||||
let health = null;
|
||||
@@ -102,6 +103,59 @@ async function saveSettings() {
|
||||
} catch (e) { console.warn('Settings sync failed:', e.message); }
|
||||
}
|
||||
|
||||
// ── Avatar Preview ──────────────────────────
|
||||
function updateAvatarPreview(dataURI) {
|
||||
const preview = document.getElementById('avatarPreview');
|
||||
const letter = document.getElementById('avatarPreviewLetter');
|
||||
const removeBtn = document.getElementById('avatarRemoveBtn');
|
||||
const existingImg = preview.querySelector('img');
|
||||
|
||||
if (dataURI) {
|
||||
letter.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = dataURI;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURI;
|
||||
img.alt = 'Avatar';
|
||||
preview.insertBefore(img, letter);
|
||||
}
|
||||
removeBtn.style.display = '';
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
const name = API.user?.display_name || API.user?.username || '?';
|
||||
letter.textContent = name[0].toUpperCase();
|
||||
letter.style.display = '';
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Preset avatar preview (admin form)
|
||||
function updatePresetAvatarPreview(dataURI) {
|
||||
const preview = document.getElementById('presetAvatarPreview');
|
||||
const placeholder = document.getElementById('presetAvatarPlaceholder');
|
||||
const removeBtn = document.getElementById('presetAvatarRemoveBtn');
|
||||
if (!preview) return;
|
||||
const existingImg = preview.querySelector('img');
|
||||
|
||||
if (dataURI) {
|
||||
placeholder.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = dataURI;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURI;
|
||||
img.alt = 'Preset avatar';
|
||||
preview.insertBefore(img, placeholder);
|
||||
}
|
||||
removeBtn.style.display = '';
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
placeholder.style.display = '';
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Models ───────────────────────────────────
|
||||
|
||||
// Client-side known model capabilities — used when backend hasn't synced yet.
|
||||
@@ -188,6 +242,7 @@ async function fetchModels() {
|
||||
isPreset,
|
||||
presetId: m.preset_id || null,
|
||||
presetScope: m.preset_scope || null,
|
||||
presetAvatar: m.preset_avatar || null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -843,6 +898,41 @@ function initListeners() {
|
||||
document.getElementById('profileChangePwForm').style.display = '';
|
||||
});
|
||||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
||||
|
||||
// Avatar upload
|
||||
document.getElementById('avatarUploadBtn').addEventListener('click', () => {
|
||||
document.getElementById('avatarFileInput').click();
|
||||
});
|
||||
document.getElementById('avatarFileInput').addEventListener('change', async function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const data = await API.uploadAvatar(reader.result);
|
||||
if (data.avatar) {
|
||||
API.user.avatar = data.avatar;
|
||||
API.saveTokens();
|
||||
updateAvatarPreview(data.avatar);
|
||||
UI.updateUser();
|
||||
UI.toast('Avatar updated');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
document.getElementById('avatarRemoveBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await API.deleteAvatar();
|
||||
API.user.avatar = null;
|
||||
API.saveTokens();
|
||||
updateAvatarPreview(null);
|
||||
UI.updateUser();
|
||||
UI.toast('Avatar removed');
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
});
|
||||
|
||||
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
||||
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||
@@ -907,6 +997,7 @@ function initListeners() {
|
||||
document.getElementById('adminPresetIcon').value = '';
|
||||
document.getElementById('adminPresetTemp').value = '';
|
||||
document.getElementById('adminPresetMaxTokens').value = '';
|
||||
updatePresetAvatarPreview(null);
|
||||
const f = document.getElementById('adminAddPresetForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
@@ -914,9 +1005,55 @@ function initListeners() {
|
||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||
_editingPresetId = null;
|
||||
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
|
||||
updatePresetAvatarPreview(null);
|
||||
});
|
||||
document.getElementById('adminCreatePresetBtn')?.addEventListener('click', createAdminPreset);
|
||||
|
||||
// Admin — preset avatar
|
||||
document.getElementById('presetAvatarUploadBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('presetAvatarFileInput').click();
|
||||
});
|
||||
document.getElementById('presetAvatarFileInput')?.addEventListener('change', async function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
if (!_editingPresetId) {
|
||||
// For new presets: show preview, upload after create
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => updatePresetAvatarPreview(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
return;
|
||||
}
|
||||
// For existing presets: upload immediately
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
|
||||
if (data.avatar) {
|
||||
updatePresetAvatarPreview(data.avatar);
|
||||
await UI.loadAdminPresets(true);
|
||||
await fetchModels();
|
||||
UI.toast('Preset avatar updated');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
document.getElementById('presetAvatarRemoveBtn')?.addEventListener('click', async () => {
|
||||
if (!_editingPresetId) {
|
||||
updatePresetAvatarPreview(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await API.adminDeletePresetAvatar(_editingPresetId);
|
||||
updatePresetAvatarPreview(null);
|
||||
await UI.loadAdminPresets(true);
|
||||
await fetchModels();
|
||||
UI.toast('Preset avatar removed');
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
});
|
||||
|
||||
// Admin — banner controls
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
@@ -1195,6 +1332,8 @@ function editAdminPreset(id) {
|
||||
if (modelSel) modelSel.value = p.base_model_id || '';
|
||||
const cfgSel = document.getElementById('adminPresetConfig');
|
||||
if (cfgSel) cfgSel.value = p.api_config_id || '';
|
||||
// Avatar preview
|
||||
updatePresetAvatarPreview(p.avatar || null);
|
||||
document.getElementById('adminCreatePresetBtn').textContent = 'Update';
|
||||
}
|
||||
|
||||
@@ -1221,13 +1360,24 @@ async function createAdminPreset() {
|
||||
if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok;
|
||||
|
||||
try {
|
||||
let presetId = _editingPresetId;
|
||||
if (_editingPresetId) {
|
||||
await API.adminUpdatePreset(_editingPresetId, preset);
|
||||
UI.toast('Preset updated', 'success');
|
||||
} else {
|
||||
await API.adminCreatePreset(preset);
|
||||
const result = await API.adminCreatePreset(preset);
|
||||
presetId = result?.id || result?.preset?.id;
|
||||
UI.toast('Preset created', 'success');
|
||||
}
|
||||
|
||||
// Upload pending avatar for new presets
|
||||
const pendingImg = document.querySelector('#presetAvatarPreview img');
|
||||
if (!_editingPresetId && presetId && pendingImg?.src?.startsWith('data:')) {
|
||||
try {
|
||||
await API.adminUploadPresetAvatar(presetId, pendingImg.src);
|
||||
} catch (e) { console.warn('Preset avatar upload failed:', e.message); }
|
||||
}
|
||||
|
||||
_editingPresetId = null;
|
||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
|
||||
@@ -1237,6 +1387,7 @@ async function createAdminPreset() {
|
||||
document.getElementById('adminPresetIcon').value = '';
|
||||
document.getElementById('adminPresetTemp').value = '';
|
||||
document.getElementById('adminPresetMaxTokens').value = '';
|
||||
updatePresetAvatarPreview(null);
|
||||
await UI.loadAdminPresets();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
@@ -1563,6 +1714,115 @@ async function deleteNote() {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Branding ────────────────────────────────
|
||||
|
||||
function initBranding() {
|
||||
const b = window.__BRANDING__;
|
||||
if (!b || typeof b !== 'object') return;
|
||||
|
||||
const brandBase = (window.__BASE__ || '') + '/branding/';
|
||||
|
||||
// Accent color → CSS custom property
|
||||
if (b.accent_color) {
|
||||
document.documentElement.style.setProperty('--accent-color', b.accent_color);
|
||||
}
|
||||
|
||||
// Page title
|
||||
if (b.org_name) {
|
||||
document.title = b.org_name;
|
||||
}
|
||||
|
||||
// Sidebar brand text
|
||||
const sidebarText = document.getElementById('brandSidebarText');
|
||||
if (sidebarText && b.org_name) sidebarText.textContent = b.org_name;
|
||||
|
||||
// Splash hero wordmark
|
||||
const wordmark = document.getElementById('brandWordmark');
|
||||
if (wordmark && b.org_name) wordmark.textContent = b.org_name;
|
||||
|
||||
// Splash headline — replace if branding provides it
|
||||
if (b.headline) {
|
||||
const headline = document.getElementById('brandHeadline');
|
||||
if (headline) headline.textContent = b.headline;
|
||||
}
|
||||
|
||||
// Splash tagline
|
||||
const tagline = document.getElementById('brandTagline');
|
||||
if (tagline && b.tagline) tagline.textContent = b.tagline;
|
||||
|
||||
// Auth card text
|
||||
if (b.org_name) {
|
||||
const authHeader = document.querySelector('.auth-card-header h2');
|
||||
if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name;
|
||||
const authSub = document.querySelector('.auth-card-header p');
|
||||
if (authSub) authSub.textContent = 'Sign in to continue';
|
||||
}
|
||||
const authFooter = document.getElementById('brandAuthFooter');
|
||||
if (authFooter && b.tagline) authFooter.textContent = b.tagline;
|
||||
|
||||
// Logo — replace SVG with <img> in splash hero
|
||||
if (b.logo) {
|
||||
const logoUrl = brandBase + b.logo;
|
||||
|
||||
const logoEl = document.getElementById('brandLogo');
|
||||
if (logoEl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = logoUrl;
|
||||
img.alt = b.org_name || 'Logo';
|
||||
img.className = 'hero-logo-img';
|
||||
img.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
const svg = logoEl.querySelector('svg');
|
||||
if (svg) svg.style.display = '';
|
||||
};
|
||||
const svg = logoEl.querySelector('svg');
|
||||
if (svg) svg.style.display = 'none';
|
||||
logoEl.appendChild(img);
|
||||
}
|
||||
|
||||
// Sidebar logo — replace emoji with small img
|
||||
const sidebarLogo = document.getElementById('brandSidebarLogo');
|
||||
if (sidebarLogo) {
|
||||
const originalEmoji = sidebarLogo.textContent;
|
||||
const img = document.createElement('img');
|
||||
img.src = logoUrl;
|
||||
img.alt = '';
|
||||
img.className = 'brand-logo-img';
|
||||
img.onerror = function() {
|
||||
this.remove();
|
||||
sidebarLogo.textContent = originalEmoji;
|
||||
};
|
||||
sidebarLogo.textContent = '';
|
||||
sidebarLogo.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
// Favicon
|
||||
if (b.favicon) {
|
||||
const favUrl = brandBase + b.favicon;
|
||||
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
|
||||
link.href = favUrl;
|
||||
});
|
||||
}
|
||||
|
||||
// Feature pills — custom array replaces defaults, empty array hides them
|
||||
if (b.pills !== undefined) {
|
||||
const pillsEl = document.getElementById('brandPills');
|
||||
if (pillsEl) {
|
||||
if (!Array.isArray(b.pills) || b.pills.length === 0) {
|
||||
pillsEl.style.display = 'none';
|
||||
} else {
|
||||
pillsEl.innerHTML = b.pills.map(p => {
|
||||
const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : '';
|
||||
return `<div class="hero-pill${style}"><span class="pill-icon">${p.icon || ''}</span> ${p.text}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎨 Branding applied:', b.org_name || '(defaults)');
|
||||
}
|
||||
|
||||
// ── Banners ──────────────────────────────────
|
||||
|
||||
async function initBanners() {
|
||||
|
||||
66
src/js/ui.js
66
src/js/ui.js
@@ -2,6 +2,26 @@
|
||||
// Chat Switchboard – UI
|
||||
// ==========================================
|
||||
|
||||
// ── Avatar Helper ────────────────────────────
|
||||
// Returns HTML for a message avatar: <img> if data URI available, emoji fallback.
|
||||
function avatarHTML(role, avatarDataURI) {
|
||||
if (avatarDataURI) {
|
||||
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
|
||||
}
|
||||
return role === 'user' ? '👤' : '🤖';
|
||||
}
|
||||
|
||||
// Look up the current model/preset avatar for assistant messages.
|
||||
function assistantAvatarURI(msg) {
|
||||
// Try to find preset avatar from the model that generated this message
|
||||
const modelId = msg?.model || msg?.preset_id;
|
||||
if (modelId) {
|
||||
const m = App.models.find(x => x.id === modelId || x.presetId === modelId);
|
||||
if (m?.presetAvatar) return m.presetAvatar;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const UI = {
|
||||
|
||||
// ── Sidebar ──────────────────────────────
|
||||
@@ -132,7 +152,7 @@ const UI = {
|
||||
return `
|
||||
<div class="message ${msg.role}" data-msg-id="${msgId}">
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head">
|
||||
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||||
@@ -208,11 +228,13 @@ const UI = {
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
|
||||
const label = modelName || 'Assistant';
|
||||
const currentModel = App.models.find(m => m.id === App.settings.model);
|
||||
const streamAvatar = currentModel?.presetAvatar || null;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message assistant';
|
||||
div.innerHTML = `
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">🤖</div>
|
||||
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
|
||||
<div class="msg-tools" id="streamTools" style="display:none"></div>
|
||||
@@ -394,7 +416,8 @@ const UI = {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
div.innerHTML = `<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
|
||||
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||
div.addEventListener('click', () => {
|
||||
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
||||
UI._closeModelDropdown();
|
||||
@@ -475,7 +498,31 @@ const UI = {
|
||||
updateUser() {
|
||||
const name = API.user?.display_name || API.user?.username || 'User';
|
||||
const letter = (name[0] || '?').toUpperCase();
|
||||
document.getElementById('avatarLetter').textContent = letter;
|
||||
const avatarEl = document.getElementById('userAvatar');
|
||||
const letterEl = document.getElementById('avatarLetter');
|
||||
// Show avatar image or initial letter
|
||||
let existingImg = avatarEl.querySelector('.user-avatar-img');
|
||||
if (API.user?.avatar) {
|
||||
letterEl.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = API.user.avatar;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = API.user.avatar;
|
||||
img.alt = '';
|
||||
img.className = 'user-avatar-img';
|
||||
img.onerror = function() {
|
||||
this.remove();
|
||||
letterEl.style.display = '';
|
||||
letterEl.textContent = letter;
|
||||
};
|
||||
avatarEl.insertBefore(img, letterEl);
|
||||
}
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
letterEl.style.display = '';
|
||||
letterEl.textContent = letter;
|
||||
}
|
||||
document.getElementById('userName').textContent = name;
|
||||
},
|
||||
|
||||
@@ -490,12 +537,14 @@ const UI = {
|
||||
document.getElementById('sendBtn').disabled = on;
|
||||
if (on) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
const currentModel = App.models.find(m => m.id === App.settings.model);
|
||||
const typingAvatar = currentModel?.presetAvatar || null;
|
||||
const typing = document.createElement('div');
|
||||
typing.id = 'typingIndicator';
|
||||
typing.className = 'message assistant';
|
||||
typing.innerHTML = `
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">🤖</div>
|
||||
<div class="msg-avatar">${avatarHTML('assistant', typingAvatar)}</div>
|
||||
<div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||||
</div>`;
|
||||
container.appendChild(typing);
|
||||
@@ -624,6 +673,7 @@ const UI = {
|
||||
const p = await API.getProfile();
|
||||
document.getElementById('profileDisplayName').value = p.display_name || '';
|
||||
document.getElementById('profileEmail').value = p.email || '';
|
||||
updateAvatarPreview(p.avatar || null);
|
||||
} catch (e) { /* optional */ }
|
||||
},
|
||||
|
||||
@@ -805,12 +855,14 @@ const UI = {
|
||||
UI._presetCache = {};
|
||||
el.innerHTML = list.map(p => {
|
||||
UI._presetCache[p.id] = p;
|
||||
const icon = p.icon ? p.icon + ' ' : '';
|
||||
const avatarEl = p.avatar
|
||||
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
|
||||
: (p.icon ? `<span class="preset-row-icon">${p.icon}</span>` : '');
|
||||
const scope = p.scope === 'global' ? '<span class="badge-admin">global</span>' : '<span class="badge-user">personal</span>';
|
||||
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
|
||||
return `<div class="admin-preset-row">
|
||||
<div class="preset-info">
|
||||
<strong>${icon}${esc(p.name)}</strong> ${scope} ${status}
|
||||
<strong>${avatarEl}${esc(p.name)}</strong> ${scope} ${status}
|
||||
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user