Merge pull request #32 from outis1one/claude/read-repo-YMu21
feat: frictionless custom domain management
This commit is contained in:
@@ -521,12 +521,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 https://openwebui.local 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
@@ -280,6 +280,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) => {
|
||||
@@ -566,44 +579,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 || [];
|
||||
@@ -616,12 +704,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}">×</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">✎</button>
|
||||
<button class="btn btn-sm btn-danger btn-remove-domain" data-index="${i}" title="Remove">×</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);
|
||||
@@ -630,7 +792,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 + '/*'] });
|
||||
@@ -638,6 +799,7 @@ function renderDomains() {
|
||||
}
|
||||
|
||||
renderDomains();
|
||||
renderSuggestedDomains();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,6 +244,19 @@
|
||||
<label class="toggle"><input type="checkbox" id="optProperNouns"><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>
|
||||
|
||||
@@ -244,6 +244,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');
|
||||
@@ -908,6 +917,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">×</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');
|
||||
|
||||
Reference in New Issue
Block a user