feat: frictionless custom domain management

- Edit domains inline (pencil icon, saves on Enter)
- Suggested domains: clickable chips for popular AI/dev/collab sites
- Bulk add: paste multiple domains at once (one per line or comma-separated)
- Popup domain management: add/remove/suggestions directly from the popup
- Permission revoke on removal + suggested list updates dynamically

https://claude.ai/code/session_01KF4i7Ra7zCEDskxDBaNtcT
This commit is contained in:
Claude
2026-03-27 21:32:31 +00:00
parent e6e9894d99
commit c8673d5b1c
4 changed files with 336 additions and 25 deletions
+22 -3
View File
@@ -519,12 +519,31 @@
<h2>Custom Domains</h2>
<p class="section-desc">Add domains for self-hosted AI services (like OpenWebUI). The extension will activate on these domains in addition to the built-in ones.</p>
<div class="domain-list" id="domainList"></div>
<div class="add-row">
<input type="text" id="newDomain" placeholder="https://ai.myserver.com" class="input" style="flex:2">
<div class="add-row" style="flex-wrap:wrap;gap:8px">
<input type="text" id="newDomain" placeholder="https://ai.myserver.com" class="input" style="flex:2;min-width:200px">
<button class="btn btn-primary" id="btnAddDomain">Add Domain</button>
<button class="btn" id="btnBulkAddDomains">Bulk Add</button>
</div>
<!-- Suggested domains -->
<div style="margin-top:10px">
<label style="font-size:12px;font-weight:500;color:#374151;display:block;margin-bottom:4px">Quick add popular sites:</label>
<div id="suggestedDomains" style="display:flex;flex-wrap:wrap;gap:4px"></div>
</div>
<!-- Bulk add textarea (hidden by default) -->
<div id="bulkDomainSection" style="display:none;margin-top:10px;padding:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px">
<label style="font-size:12px;font-weight:500;color:#374151;display:block;margin-bottom:4px">Paste domains (one per line):</label>
<textarea id="bulkDomainText" rows="5" placeholder="https://ai.example.com&#10;https://openwebui.local&#10;myai.company.com" style="width:100%;box-sizing:border-box;font-size:12px;font-family:monospace;padding:8px;border:1px solid #d1d5db;border-radius:6px;resize:vertical"></textarea>
<div style="display:flex;gap:8px;margin-top:6px">
<button class="btn btn-primary btn-sm" id="btnApplyBulkDomains">Add All</button>
<button class="btn btn-sm" id="btnCancelBulkDomains">Cancel</button>
<span id="bulkDomainStatus" style="font-size:11px;color:#6b7280;align-self:center"></span>
</div>
</div>
<p class="section-desc" style="margin-top:8px;margin-bottom:0">
When you click "Add Domain", your browser will ask you to confirm access. No extra steps needed.
When you add a domain, your browser will ask you to confirm access. No extra steps needed.
</p>
</section>
+184 -22
View File
@@ -274,6 +274,19 @@ document.addEventListener('DOMContentLoaded', async () => {
$('#newDomain').addEventListener('keydown', (e) => {
if (e.key === 'Enter') addDomain();
});
renderSuggestedDomains();
// Bulk add domains
$('#btnBulkAddDomains').addEventListener('click', () => {
const section = $('#bulkDomainSection');
section.style.display = section.style.display === 'none' ? 'block' : 'none';
});
$('#btnCancelBulkDomains').addEventListener('click', () => {
$('#bulkDomainSection').style.display = 'none';
$('#bulkDomainText').value = '';
$('#bulkDomainStatus').textContent = '';
});
$('#btnApplyBulkDomains').addEventListener('click', bulkAddDomains);
// Settings listeners
$('#showHighlights').addEventListener('change', async (e) => {
@@ -560,44 +573,119 @@ async function renderLog() {
}
// --- Custom Domains ---
async function addDomain() {
let domain = $('#newDomain').value.trim();
if (!domain) return;
// Normalize: ensure it has a protocol
// Suggested popular domains (not already built-in)
const SUGGESTED_DOMAINS = [
{ label: 'OpenWebUI', url: 'https://openwebui.local' },
{ label: 'Ollama Web', url: 'http://localhost:3000' },
{ label: 'text-generation-webui', url: 'http://localhost:7860' },
{ label: 'Jan.ai', url: 'https://jan.ai' },
{ label: 'You.com', url: 'https://you.com' },
{ label: 'Phind', url: 'https://www.phind.com' },
{ label: 'Cohere', url: 'https://coral.cohere.com' },
{ label: 'Mistral', url: 'https://chat.mistral.ai' },
{ label: 'Pi AI', url: 'https://pi.ai' },
{ label: 'Notion AI', url: 'https://www.notion.so' },
{ label: 'Quora', url: 'https://www.quora.com' },
{ label: 'Discord', url: 'https://discord.com' },
{ label: 'Slack', url: 'https://app.slack.com' },
{ label: 'Jira', url: 'https://atlassian.net' },
{ label: 'Linear', url: 'https://linear.app' },
{ label: 'Bitbucket', url: 'https://bitbucket.org' },
];
function normalizeDomain(raw) {
let domain = raw.trim();
if (!domain) return null;
if (!domain.startsWith('http://') && !domain.startsWith('https://')) {
domain = 'https://' + domain;
}
// Strip trailing slashes
domain = domain.replace(/\/+$/, '');
return domain.replace(/\/+$/, '');
}
async function addSingleDomain(domain) {
const domains = settings.customDomains || [];
if (domains.includes(domain)) {
alert('Domain already added.');
return;
}
if (domains.includes(domain)) return { added: false, reason: 'duplicate' };
// Request browser permission for this domain
try {
const granted = await api.permissions.request({
origins: [domain + '/*'],
});
if (!granted) {
alert('Permission denied. The extension needs access to this domain to work.');
return;
}
const granted = await api.permissions.request({ origins: [domain + '/*'] });
if (!granted) return { added: false, reason: 'denied' };
} catch (e) {
// Firefox or older Chrome may not support optional permissions this way
console.warn('[Silent Send] Could not request permission:', e);
}
domains.push(domain);
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
return { added: true };
}
async function addDomain() {
const domain = normalizeDomain($('#newDomain').value);
if (!domain) return;
const result = await addSingleDomain(domain);
if (!result.added) {
if (result.reason === 'duplicate') alert('Domain already added.');
else alert('Permission denied. The extension needs access to this domain to work.');
return;
}
renderDomains();
renderSuggestedDomains();
$('#newDomain').value = '';
}
async function bulkAddDomains() {
const text = $('#bulkDomainText').value;
const lines = text.split(/[\n,]+/).map(l => l.trim()).filter(Boolean);
if (lines.length === 0) return;
let added = 0, skipped = 0;
for (const line of lines) {
const domain = normalizeDomain(line);
if (!domain) { skipped++; continue; }
const result = await addSingleDomain(domain);
if (result.added) added++;
else skipped++;
}
renderDomains();
renderSuggestedDomains();
$('#bulkDomainStatus').textContent = `Added ${added}, skipped ${skipped}`;
if (added > 0) $('#bulkDomainText').value = '';
}
function renderSuggestedDomains() {
const container = $('#suggestedDomains');
if (!container) return;
const domains = settings.customDomains || [];
// Filter out suggestions that are already added
const available = SUGGESTED_DOMAINS.filter(s => !domains.includes(s.url));
if (available.length === 0) {
safeHTML(container, '<span style="font-size:11px;color:#9ca3af">All suggestions added!</span>');
return;
}
safeHTML(container, available.map(s =>
`<button class="btn-suggest-domain" data-url="${escapeHtml(s.url)}" title="${escapeHtml(s.url)}" style="font-size:11px;padding:3px 8px;border:1px solid #d1d5db;border-radius:12px;background:#fff;cursor:pointer;color:#374151;white-space:nowrap">+ ${escapeHtml(s.label)}</button>`
).join(''));
container.querySelectorAll('.btn-suggest-domain').forEach(btn => {
btn.addEventListener('click', async () => {
const domain = btn.dataset.url;
const result = await addSingleDomain(domain);
if (result.added) {
renderDomains();
renderSuggestedDomains();
} else if (result.reason === 'denied') {
alert('Permission denied for ' + domain);
}
});
});
}
function renderDomains() {
const list = $('#domainList');
const domains = settings.customDomains || [];
@@ -610,12 +698,86 @@ function renderDomains() {
safeHTML(list, domains
.map((d, i) => `
<div class="domain-item" style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
<span style="font-size:13px;font-family:monospace">${escapeHtml(d)}</span>
<button class="btn btn-sm btn-danger btn-remove-domain" data-index="${i}">&times;</button>
<span class="domain-text" style="font-size:13px;font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">${escapeHtml(d)}</span>
<div style="display:flex;gap:4px;margin-left:8px">
<button class="btn btn-sm btn-edit-domain" data-index="${i}" title="Edit">&#9998;</button>
<button class="btn btn-sm btn-danger btn-remove-domain" data-index="${i}" title="Remove">&times;</button>
</div>
</div>
`)
.join(''));
// Edit handlers
list.querySelectorAll('.btn-edit-domain').forEach((btn) => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.index, 10);
const domains = settings.customDomains || [];
const current = domains[idx];
const row = btn.closest('.domain-item');
const textEl = row.querySelector('.domain-text');
// Replace text with input
const input = document.createElement('input');
input.type = 'text';
input.value = current;
input.style.cssText = 'flex:1;font-size:12px;font-family:monospace;padding:3px 6px;border:1px solid #3b82f6;border-radius:4px;outline:none;min-width:0';
textEl.replaceWith(input);
input.focus();
input.select();
// Replace edit button with save button
btn.textContent = '\u2713';
btn.title = 'Save';
btn.style.color = '#10b981';
const save = async () => {
const newDomain = normalizeDomain(input.value);
if (!newDomain || newDomain === current) {
renderDomains();
return;
}
if (domains.includes(newDomain)) {
alert('Domain already exists.');
renderDomains();
return;
}
// Request permission for new domain
try {
const granted = await api.permissions.request({ origins: [newDomain + '/*'] });
if (!granted) {
alert('Permission denied for ' + newDomain);
renderDomains();
return;
}
} catch (e) { /* non-fatal */ }
// Revoke old permission
try {
await api.permissions.remove({ origins: [current + '/*'] });
} catch (e) { /* non-fatal */ }
domains[idx] = newDomain;
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
renderDomains();
renderSuggestedDomains();
};
btn.onclick = save;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') renderDomains();
});
input.addEventListener('blur', () => {
// Small delay to allow button click to fire first
setTimeout(() => { if (document.contains(input)) renderDomains(); }, 150);
});
});
});
// Remove handlers
list.querySelectorAll('.btn-remove-domain').forEach((btn) => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.index, 10);
@@ -624,7 +786,6 @@ function renderDomains() {
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
// Revoke browser permission for the removed domain
if (removed) {
try {
await api.permissions.remove({ origins: [removed + '/*'] });
@@ -632,6 +793,7 @@ function renderDomains() {
}
renderDomains();
renderSuggestedDomains();
});
});
}
+13
View File
@@ -236,6 +236,19 @@
<label class="toggle"><input type="checkbox" id="optDocPreview" checked><span class="toggle-slider"></span></label>
</div>
<div style="margin-top:12px;padding-top:10px;border-top:1px solid #e5e7eb">
<div class="setting-label" style="margin-bottom:6px">
<strong>Custom Domains</strong>
<span class="setting-desc">Add sites beyond the built-in list</span>
</div>
<div id="popupDomainList" style="max-height:120px;overflow-y:auto;margin-bottom:6px"></div>
<div style="display:flex;gap:4px">
<input type="text" id="popupNewDomain" placeholder="https://ai.example.com" style="flex:1;font-size:11px;padding:4px 6px;border:1px solid #d1d5db;border-radius:4px;min-width:0">
<button class="btn" id="btnPopupAddDomain" style="font-size:11px;padding:4px 8px;white-space:nowrap">Add</button>
</div>
<div id="popupDomainSuggestions" style="margin-top:6px;display:flex;flex-wrap:wrap;gap:3px"></div>
</div>
<div style="margin-top:12px;padding-top:10px;border-top:1px solid #e5e7eb">
<button class="btn" id="btnOpenFullOptions" style="width:100%;font-size:12px">Open Full Options Page</button>
<p class="help-text" style="margin-top:6px;text-align:center">Sync, encryption, org, version history, import/export, and more</p>
+117
View File
@@ -242,6 +242,15 @@ async function initUnlockedUI() {
});
}
// --- Popup domain management ---
renderPopupDomains();
renderPopupDomainSuggestions();
$('#btnPopupAddDomain').addEventListener('click', popupAddDomain);
$('#popupNewDomain').addEventListener('keydown', (e) => {
if (e.key === 'Enter') popupAddDomain();
});
// Update privacy note based on encryption state
const encEnabled = await Storage._isAtRestEncryptionEnabled();
const encNote = $('#privacyEncNote');
@@ -900,6 +909,114 @@ function updateStatusDot() {
dot.classList.toggle('disabled', !settings.enabled);
}
// --- Popup Domain Management ---
const POPUP_SUGGESTED_DOMAINS = [
{ label: 'Mistral', url: 'https://chat.mistral.ai' },
{ label: 'Cohere', url: 'https://coral.cohere.com' },
{ label: 'Phind', url: 'https://www.phind.com' },
{ label: 'You.com', url: 'https://you.com' },
{ label: 'Pi AI', url: 'https://pi.ai' },
{ label: 'Discord', url: 'https://discord.com' },
{ label: 'Slack', url: 'https://app.slack.com' },
{ label: 'Notion', url: 'https://www.notion.so' },
{ label: 'Linear', url: 'https://linear.app' },
{ label: 'Bitbucket', url: 'https://bitbucket.org' },
];
function normalizeDomain(raw) {
let d = raw.trim();
if (!d) return null;
if (!d.startsWith('http://') && !d.startsWith('https://')) d = 'https://' + d;
return d.replace(/\/+$/, '');
}
async function popupAddDomain() {
const domain = normalizeDomain($('#popupNewDomain').value);
if (!domain) return;
const domains = settings.customDomains || [];
if (domains.includes(domain)) return;
try {
const granted = await api.permissions.request({ origins: [domain + '/*'] });
if (!granted) return;
} catch (e) { /* non-fatal */ }
domains.push(domain);
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
$('#popupNewDomain').value = '';
renderPopupDomains();
renderPopupDomainSuggestions();
}
function renderPopupDomains() {
const list = $('#popupDomainList');
if (!list) return;
const domains = settings.customDomains || [];
if (domains.length === 0) {
safeHTML(list, '<div style="font-size:11px;color:#9ca3af;padding:4px 0">No custom domains added</div>');
return;
}
safeHTML(list, domains.map((d, i) => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 6px;background:#f9fafb;border-radius:4px;margin-bottom:2px">
<span style="font-size:11px;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escapeHtml(d)}</span>
<button class="btn-popup-remove-domain" data-index="${i}" style="border:none;background:none;color:#9ca3af;cursor:pointer;font-size:14px;padding:0 2px;line-height:1" title="Remove">&times;</button>
</div>
`).join(''));
list.querySelectorAll('.btn-popup-remove-domain').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.index, 10);
const domains = settings.customDomains || [];
const removed = domains.splice(idx, 1)[0];
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
if (removed) {
try { await api.permissions.remove({ origins: [removed + '/*'] }); } catch (e) { /* non-fatal */ }
}
renderPopupDomains();
renderPopupDomainSuggestions();
});
});
}
function renderPopupDomainSuggestions() {
const container = $('#popupDomainSuggestions');
if (!container) return;
const domains = settings.customDomains || [];
const available = POPUP_SUGGESTED_DOMAINS.filter(s => !domains.includes(s.url));
if (available.length === 0) {
container.replaceChildren();
return;
}
safeHTML(container, available.slice(0, 6).map(s =>
`<button class="btn-popup-suggest" data-url="${escapeHtml(s.url)}" title="${escapeHtml(s.url)}" style="font-size:10px;padding:2px 6px;border:1px solid #e5e7eb;border-radius:10px;background:#fff;cursor:pointer;color:#6b7280;white-space:nowrap">+ ${escapeHtml(s.label)}</button>`
).join(''));
container.querySelectorAll('.btn-popup-suggest').forEach(btn => {
btn.addEventListener('click', async () => {
const domain = btn.dataset.url;
const domains = settings.customDomains || [];
if (domains.includes(domain)) return;
try {
const granted = await api.permissions.request({ origins: [domain + '/*'] });
if (!granted) return;
} catch (e) { /* non-fatal */ }
domains.push(domain);
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
renderPopupDomains();
renderPopupDomainSuggestions();
});
});
}
// --- Util ---
function escapeHtml(str) {
const div = document.createElement('div');