rspace-online/browser-extension/popup.js

561 lines
18 KiB
JavaScript

const DEFAULT_HOST = 'https://rspace.online';
let currentTab = null;
let selectedText = '';
let selectedHtml = '';
// --- Helpers ---
async function getSettings() {
const result = await chrome.storage.sync.get(['rspaceHost', 'rspaceSlug']);
return {
host: result.rspaceHost || DEFAULT_HOST,
slug: result.rspaceSlug || '',
};
}
function apiBase(settings) {
return settings.slug ? `${settings.host}/${settings.slug}/rnotes` : `${settings.host}/rnotes`;
}
async function getToken() {
const result = await chrome.storage.local.get(['encryptid_token']);
return result.encryptid_token || null;
}
function decodeToken(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
return payload;
} catch { return null; }
}
function parseTags(tagString) {
if (!tagString || !tagString.trim()) return [];
return tagString.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
}
function showStatus(message, type) {
const el = document.getElementById('status');
el.textContent = message;
el.className = `status ${type}`;
if (type === 'success') {
setTimeout(() => { el.className = 'status'; }, 3000);
}
}
// --- API calls ---
async function createNote(data) {
const token = await getToken();
const settings = await getSettings();
if (!settings.slug) {
showStatus('Configure space slug in Settings first', 'error');
return;
}
const body = {
title: data.title,
content: data.content,
type: data.type || 'CLIP',
url: data.url,
};
const notebookId = document.getElementById('notebook').value;
if (notebookId) body.notebook_id = notebookId;
const tags = parseTags(document.getElementById('tags').value);
if (tags.length > 0) body.tags = tags;
const response = await fetch(`${apiBase(settings)}/api/notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${response.status}: ${text}`);
}
return response.json();
}
async function fetchNotebooks() {
const token = await getToken();
const settings = await getSettings();
if (!settings.slug) return [];
const response = await fetch(`${apiBase(settings)}/api/notebooks`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!response.ok) return [];
const data = await response.json();
return data.notebooks || (Array.isArray(data) ? data : []);
}
// --- UI ---
async function populateNotebooks() {
const select = document.getElementById('notebook');
try {
const notebooks = await fetchNotebooks();
for (const nb of notebooks) {
const option = document.createElement('option');
option.value = nb.id;
option.textContent = nb.title;
select.appendChild(option);
}
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
if (lastNotebookId) select.value = lastNotebookId;
} catch (err) {
console.error('Failed to load notebooks:', err);
}
}
function setupNotebookMemory() {
document.getElementById('notebook').addEventListener('change', (e) => {
chrome.storage.local.set({ lastNotebookId: e.target.value });
});
}
async function init() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentTab = tab;
document.getElementById('pageTitle').textContent = tab.title || 'Untitled';
document.getElementById('pageUrl').textContent = tab.url || '';
const token = await getToken();
const claims = token ? decodeToken(token) : null;
const settings = await getSettings();
if (!claims) {
document.getElementById('userStatus').textContent = 'Not signed in';
document.getElementById('userStatus').classList.add('not-authed');
document.getElementById('authWarning').style.display = 'block';
return;
}
if (!settings.slug) {
document.getElementById('userStatus').textContent = 'No space configured';
document.getElementById('userStatus').classList.add('not-authed');
document.getElementById('authWarning').style.display = 'block';
document.getElementById('authWarning').innerHTML = 'Configure your space slug. <a id="openSettings">Open Settings</a>';
document.getElementById('openSettings')?.addEventListener('click', (e) => { e.preventDefault(); chrome.runtime.openOptionsPage(); });
return;
}
document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated';
document.getElementById('authWarning').style.display = 'none';
document.getElementById('clipPageBtn').disabled = false;
document.getElementById('unlockBtn').disabled = false;
document.getElementById('voiceBtn').disabled = false;
await populateNotebooks();
setupNotebookMemory();
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return { text: '', html: '' };
const range = selection.getRangeAt(0);
const div = document.createElement('div');
div.appendChild(range.cloneContents());
return { text: selection.toString(), html: div.innerHTML };
},
});
if (result?.result?.text) {
selectedText = result.result.text;
selectedHtml = result.result.html;
document.getElementById('clipSelectionBtn').disabled = false;
}
} catch (err) {
console.warn('Cannot access page content:', err);
}
}
// --- Event handlers ---
document.getElementById('clipPageBtn').addEventListener('click', async () => {
const btn = document.getElementById('clipPageBtn');
btn.disabled = true;
showStatus('Clipping page...', 'loading');
try {
let pageContent = '';
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: currentTab.id },
func: () => document.body.innerHTML,
});
pageContent = result?.result || '';
} catch {
pageContent = `<p>Clipped from <a href="${currentTab.url}">${currentTab.url}</a></p>`;
}
await createNote({
title: currentTab.title || 'Untitled Clip',
content: pageContent,
type: 'CLIP',
url: currentTab.url,
});
showStatus('Clipped! Note saved.', 'success');
chrome.runtime.sendMessage({ type: 'notify', title: 'Page Clipped', message: `"${currentTab.title}" saved to rSpace` });
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('clipSelectionBtn').addEventListener('click', async () => {
const btn = document.getElementById('clipSelectionBtn');
btn.disabled = true;
showStatus('Clipping selection...', 'loading');
try {
const content = selectedHtml || `<p>${selectedText}</p>`;
await createNote({
title: `Selection from ${currentTab.title || 'page'}`,
content,
type: 'CLIP',
url: currentTab.url,
});
showStatus('Selection clipped!', 'success');
chrome.runtime.sendMessage({ type: 'notify', title: 'Selection Clipped', message: 'Saved to rSpace' });
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('unlockBtn').addEventListener('click', async () => {
const btn = document.getElementById('unlockBtn');
btn.disabled = true;
showStatus('Unlocking article...', 'loading');
try {
const token = await getToken();
const settings = await getSettings();
const response = await fetch(`${apiBase(settings)}/api/articles/unlock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ url: currentTab.url }),
});
const result = await response.json();
if (result.success && result.archiveUrl) {
await createNote({
title: currentTab.title || 'Unlocked Article',
content: `<p>Unlocked via ${result.strategy}</p><p>Original: <a href="${currentTab.url}">${currentTab.url}</a></p><p>Archive: <a href="${result.archiveUrl}">${result.archiveUrl}</a></p>`,
type: 'CLIP',
url: currentTab.url,
});
showStatus(`Unlocked via ${result.strategy}! Opening...`, 'success');
chrome.tabs.create({ url: result.archiveUrl });
} else {
showStatus(result.error || 'No archived version found', 'error');
}
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('voiceBtn').addEventListener('click', async () => {
const settings = await getSettings();
const voiceUrl = settings.slug
? `${settings.host}/${settings.slug}/rnotes/voice`
: `${settings.host}/rnotes/voice`;
chrome.windows.create({ url: voiceUrl, type: 'popup', width: 400, height: 600, focused: true });
window.close();
});
document.getElementById('optionsLink').addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
document.getElementById('openSettings')?.addEventListener('click', (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
// --- Mode switching ---
document.querySelectorAll('.mode-tab').forEach((tab) => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode;
document.querySelectorAll('.mode-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
document.getElementById('clipperPanel').style.display = mode === 'clipper' ? '' : 'none';
document.getElementById('cartPanel').style.display = mode === 'cart' ? '' : 'none';
chrome.storage.local.set({ lastPopupMode: mode });
if (mode === 'cart') initCartMode();
});
});
// --- Cart helpers ---
function showCartStatus(message, type) {
const el = document.getElementById('cartStatus');
el.textContent = message;
el.className = `cart-status ${type}`;
if (type === 'success') {
setTimeout(() => { el.className = 'cart-status'; }, 3000);
}
}
async function fetchSpaces() {
const settings = await getSettings();
const token = await getToken();
try {
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${settings.host}/api/spaces`, { headers });
if (res.ok) {
const data = await res.json();
return data.spaces || data || [];
}
} catch {}
return [{ slug: 'demo', name: 'demo' }];
}
async function fetchCarts(space) {
const settings = await getSettings();
const token = await getToken();
try {
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${settings.host}/${space}/rcart/api/shopping-carts`, { headers });
if (res.ok) {
const data = await res.json();
return data.carts || [];
}
} catch {}
return [];
}
async function fetchCartSummary(space) {
const settings = await getSettings();
const token = await getToken();
try {
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${settings.host}/${space}/rcart/api/cart/summary`, { headers });
if (res.ok) return res.json();
} catch {}
return null;
}
function renderCartPreview(summary) {
const el = document.getElementById('cartPreview');
if (!summary || summary.totalItems === 0) {
el.innerHTML = '<div class="empty-msg">Cart is empty</div>';
return;
}
const vendors = summary.vendorGroups || [];
const vendorHtml = vendors.map((v) =>
`<div class="vendor-bullet">${v.domain || v.vendor || 'unknown'} (${v.count || v.items || 0})</div>`
).join('');
el.innerHTML = `
<div class="preview-card">
<div class="preview-row"><span>Items</span><span class="value">${summary.totalItems}</span></div>
<div class="preview-row"><span>Total</span><span class="value">$${(summary.totalAmount || 0).toFixed(2)}</span></div>
${vendors.length > 0 ? `<div class="vendor-list">${vendorHtml}</div>` : ''}
</div>`;
}
async function initCartMode() {
const spaceSelect = document.getElementById('spaceSelect');
const cartSelect = document.getElementById('cartSelect');
// Populate spaces
const spaces = await fetchSpaces();
const { selectedSpace } = await chrome.storage.sync.get(['selectedSpace']);
const currentSpace = selectedSpace || 'demo';
spaceSelect.innerHTML = '';
for (const s of spaces) {
const opt = document.createElement('option');
opt.value = s.slug || s.name || s;
opt.textContent = s.name || s.slug || s;
spaceSelect.appendChild(opt);
}
// Ensure current space option exists
if (!spaceSelect.querySelector(`option[value="${currentSpace}"]`)) {
const opt = document.createElement('option');
opt.value = currentSpace;
opt.textContent = currentSpace;
spaceSelect.prepend(opt);
}
spaceSelect.value = currentSpace;
// Load carts for this space
await loadCartsForSpace(currentSpace);
}
async function loadCartsForSpace(space) {
const cartSelect = document.getElementById('cartSelect');
cartSelect.innerHTML = '<option value="">Loading...</option>';
const carts = await fetchCarts(space);
const { selectedCartId } = await chrome.storage.sync.get(['selectedCartId']);
cartSelect.innerHTML = '';
if (carts.length === 0) {
cartSelect.innerHTML = '<option value="">No carts</option>';
renderCartPreview(null);
return;
}
for (const c of carts) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name || c.id;
cartSelect.appendChild(opt);
}
if (selectedCartId && cartSelect.querySelector(`option[value="${selectedCartId}"]`)) {
cartSelect.value = selectedCartId;
}
// Load preview
const summary = await fetchCartSummary(space);
renderCartPreview(summary);
}
// Space change handler
document.getElementById('spaceSelect').addEventListener('change', async (e) => {
const space = e.target.value;
await chrome.storage.sync.set({ selectedSpace: space });
await loadCartsForSpace(space);
chrome.runtime.sendMessage({ type: 'cart-settings-changed', space });
});
// Cart change handler
document.getElementById('cartSelect').addEventListener('change', async (e) => {
await chrome.storage.sync.set({ selectedCartId: e.target.value });
});
// Add to Cart
document.getElementById('addToCartBtn').addEventListener('click', async () => {
const btn = document.getElementById('addToCartBtn');
btn.disabled = true;
showCartStatus('Detecting product...', 'loading');
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const settings = await getSettings();
const token = await getToken();
const { selectedSpace } = await chrome.storage.sync.get(['selectedSpace']);
const space = selectedSpace || 'demo';
// Try to detect product info from the page
let product = null;
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
// Try JSON-LD
const ldScripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const s of ldScripts) {
try {
const data = JSON.parse(s.textContent);
const item = data['@type'] === 'Product' ? data : (Array.isArray(data['@graph']) ? data['@graph'].find(i => i['@type'] === 'Product') : null);
if (item) {
return {
name: item.name,
image: item.image?.url || item.image?.[0] || item.image,
price: item.offers?.price || item.offers?.[0]?.price,
currency: item.offers?.priceCurrency || item.offers?.[0]?.priceCurrency,
};
}
} catch {}
}
// Fallback to meta tags
const title = document.querySelector('meta[property="og:title"]')?.content || document.title;
const image = document.querySelector('meta[property="og:image"]')?.content;
return { name: title, image };
},
});
if (result?.result?.name) product = result.result;
} catch {}
showCartStatus('Adding to cart...', 'loading');
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const body = { url: tab.url };
if (product) body.product = product;
const res = await fetch(`${settings.host}/${space}/rcart/api/cart/quick-add`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
const added = await res.json();
showCartStatus(`Added: ${added.name || product?.name || 'item'}`, 'success');
// Update preview
const summary = await fetchCartSummary(space);
renderCartPreview(summary);
// Notify background to update badge
chrome.runtime.sendMessage({ type: 'cart-item-added', space });
} catch (err) {
showCartStatus(`Error: ${err.message}`, 'error');
} finally {
btn.disabled = false;
}
});
// View Cart
document.getElementById('viewCartBtn').addEventListener('click', async () => {
const settings = await getSettings();
const { selectedSpace } = await chrome.storage.sync.get(['selectedSpace']);
const space = selectedSpace || 'demo';
chrome.tabs.create({ url: `${settings.host}/${space}/rcart` });
window.close();
});
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
await init();
// Restore last popup mode
const { lastPopupMode } = await chrome.storage.local.get(['lastPopupMode']);
if (lastPopupMode === 'cart') {
document.querySelectorAll('.mode-tab').forEach((t) => t.classList.remove('active'));
document.querySelector('.mode-tab[data-mode="cart"]').classList.add('active');
document.getElementById('clipperPanel').style.display = 'none';
document.getElementById('cartPanel').style.display = '';
initCartMode();
}
});