diff --git a/browser-extension/background.js b/browser-extension/background.js new file mode 100644 index 0000000..31120cb --- /dev/null +++ b/browser-extension/background.js @@ -0,0 +1,247 @@ +const DEFAULT_HOST = 'https://rnotes.online'; + +// --- Context Menu Setup --- + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: 'clip-page', + title: 'Clip page to rNotes', + contexts: ['page'], + }); + + chrome.contextMenus.create({ + id: 'save-link', + title: 'Save link to rNotes', + contexts: ['link'], + }); + + chrome.contextMenus.create({ + id: 'save-image', + title: 'Save image to rNotes', + contexts: ['image'], + }); + + chrome.contextMenus.create({ + id: 'clip-selection', + title: 'Clip selection to rNotes', + contexts: ['selection'], + }); +}); + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rnotesHost']); + return { + host: result.rnotesHost || DEFAULT_HOST, + }; +} + +async function getToken() { + const result = await chrome.storage.local.get(['encryptid_token']); + return result.encryptid_token || null; +} + +async function getDefaultNotebook() { + const result = await chrome.storage.local.get(['lastNotebookId']); + return result.lastNotebookId || null; +} + +function showNotification(title, message) { + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon-128.png', + title: title, + message: message, + }); +} + +async function createNote(data) { + const token = await getToken(); + if (!token) { + showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.'); + return; + } + + const settings = await getSettings(); + const notebookId = await getDefaultNotebook(); + + const body = { + title: data.title, + content: data.content, + type: data.type || 'CLIP', + url: data.url, + }; + + if (notebookId) body.notebookId = notebookId; + if (data.fileUrl) body.fileUrl = data.fileUrl; + if (data.mimeType) body.mimeType = data.mimeType; + if (data.fileSize) body.fileSize = data.fileSize; + + const response = await fetch(`${settings.host}/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 uploadImage(imageUrl) { + const token = await getToken(); + const settings = await getSettings(); + + // Fetch the image + const imgResponse = await fetch(imageUrl); + const blob = await imgResponse.blob(); + + // Extract filename + let filename; + try { + const urlPath = new URL(imageUrl).pathname; + filename = urlPath.split('/').pop() || `image-${Date.now()}.jpg`; + } catch { + filename = `image-${Date.now()}.jpg`; + } + + // Upload to rNotes + const formData = new FormData(); + formData.append('file', blob, filename); + + const response = await fetch(`${settings.host}/api/uploads`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Upload failed: ${response.status} ${text}`); + } + + return response.json(); +} + +// --- Context Menu Handler --- + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + try { + switch (info.menuItemId) { + case 'clip-page': { + // Get page HTML + let content = ''; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => document.body.innerHTML, + }); + content = result?.result || ''; + } catch { + content = `

Clipped from ${tab.url}

`; + } + + await createNote({ + title: tab.title || 'Untitled Clip', + content: content, + type: 'CLIP', + url: tab.url, + }); + + showNotification('Page Clipped', `"${tab.title}" saved to rNotes`); + break; + } + + case 'save-link': { + const linkUrl = info.linkUrl; + const linkText = info.selectionText || linkUrl; + + await createNote({ + title: linkText, + content: `

${linkText}

Found on: ${tab.title}

`, + type: 'BOOKMARK', + url: linkUrl, + }); + + showNotification('Link Saved', `Bookmark saved to rNotes`); + break; + } + + case 'save-image': { + const imageUrl = info.srcUrl; + + // Upload the image first + const upload = await uploadImage(imageUrl); + + // Create IMAGE note with file reference + await createNote({ + title: `Image from ${tab.title || 'page'}`, + content: `

Clipped image

Source: ${tab.title}

`, + type: 'IMAGE', + url: tab.url, + fileUrl: upload.url, + mimeType: upload.mimeType, + fileSize: upload.size, + }); + + showNotification('Image Saved', `Image saved to rNotes`); + break; + } + + case 'clip-selection': { + // Get selection HTML + let content = ''; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return ''; + const range = selection.getRangeAt(0); + const div = document.createElement('div'); + div.appendChild(range.cloneContents()); + return div.innerHTML; + }, + }); + content = result?.result || ''; + } catch { + content = `

${info.selectionText || ''}

`; + } + + if (!content && info.selectionText) { + content = `

${info.selectionText}

`; + } + + await createNote({ + title: `Selection from ${tab.title || 'page'}`, + content: content, + type: 'CLIP', + url: tab.url, + }); + + showNotification('Selection Clipped', `Saved to rNotes`); + break; + } + } + } catch (err) { + console.error('Context menu action failed:', err); + showNotification('rNotes Error', err.message || 'Failed to save'); + } +}); + +// --- Message Handler (from popup) --- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'notify') { + showNotification(message.title, message.message); + } +}); diff --git a/browser-extension/icons/icon-128.png b/browser-extension/icons/icon-128.png new file mode 100644 index 0000000..1e296f9 Binary files /dev/null and b/browser-extension/icons/icon-128.png differ diff --git a/browser-extension/icons/icon-16.png b/browser-extension/icons/icon-16.png new file mode 100644 index 0000000..62b0620 Binary files /dev/null and b/browser-extension/icons/icon-16.png differ diff --git a/browser-extension/icons/icon-48.png b/browser-extension/icons/icon-48.png new file mode 100644 index 0000000..3851e82 Binary files /dev/null and b/browser-extension/icons/icon-48.png differ diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json new file mode 100644 index 0000000..315fc00 --- /dev/null +++ b/browser-extension/manifest.json @@ -0,0 +1,37 @@ +{ + "manifest_version": 3, + "name": "rNotes Web Clipper", + "version": "1.0.0", + "description": "Clip pages, text, links, and images to rNotes.online", + "permissions": [ + "activeTab", + "contextMenus", + "storage", + "notifications" + ], + "host_permissions": [ + "https://rnotes.online/*", + "https://encryptid.jeffemmett.com/*", + "*://*/*" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "background": { + "service_worker": "background.js" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": false + } +} diff --git a/browser-extension/options.html b/browser-extension/options.html new file mode 100644 index 0000000..9946840 --- /dev/null +++ b/browser-extension/options.html @@ -0,0 +1,231 @@ + + + + + + + +

rNotes Web Clipper Settings

+ + +
+

Connection

+
+ + +
The URL of your rNotes instance
+
+
+ + +
+

Authentication

+
+ Not signed in +
+ +
+
+ + +
Opens rNotes in a new tab. Sign in with your passkey.
+
+
+ + +
After signing in, copy the extension token and paste it here.
+
+
+ +
+
+ + +
+ + +
+

Default Notebook

+
+ + +
Pre-selected notebook when clipping
+
+
+ + +
+ + +
+ +
+ + + + diff --git a/browser-extension/options.js b/browser-extension/options.js new file mode 100644 index 0000000..55858c5 --- /dev/null +++ b/browser-extension/options.js @@ -0,0 +1,179 @@ +const DEFAULT_HOST = 'https://rnotes.online'; + +// --- Helpers --- + +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 showStatus(message, type) { + const el = document.getElementById('status'); + el.textContent = message; + el.className = `status ${type}`; + if (type === 'success') { + setTimeout(() => { el.className = 'status'; }, 3000); + } +} + +// --- Auth UI --- + +async function updateAuthUI() { + const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); + const claims = encryptid_token ? decodeToken(encryptid_token) : null; + + const authStatus = document.getElementById('authStatus'); + const loginSection = document.getElementById('loginSection'); + const loggedInSection = document.getElementById('loggedInSection'); + + if (claims) { + const username = claims.username || claims.sub?.slice(0, 20) || 'Authenticated'; + authStatus.textContent = `Signed in as ${username}`; + authStatus.className = 'auth-status authed'; + loginSection.style.display = 'none'; + loggedInSection.style.display = 'block'; + } else { + authStatus.textContent = 'Not signed in'; + authStatus.className = 'auth-status not-authed'; + loginSection.style.display = 'block'; + loggedInSection.style.display = 'none'; + } +} + +async function populateNotebooks() { + const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); + if (!encryptid_token) return; + + const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST; + + try { + const response = await fetch(`${host}/api/notebooks`, { + headers: { 'Authorization': `Bearer ${encryptid_token}` }, + }); + + if (!response.ok) return; + + const notebooks = await response.json(); + const select = document.getElementById('defaultNotebook'); + + // Clear existing options (keep first) + while (select.options.length > 1) { + select.remove(1); + } + + for (const nb of notebooks) { + const option = document.createElement('option'); + option.value = nb.id; + option.textContent = nb.title; + select.appendChild(option); + } + + // Restore saved default + const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); + if (lastNotebookId) { + select.value = lastNotebookId; + } + } catch (err) { + console.error('Failed to load notebooks:', err); + } +} + +// --- Load settings --- + +async function loadSettings() { + const result = await chrome.storage.sync.get(['rnotesHost']); + document.getElementById('host').value = result.rnotesHost || DEFAULT_HOST; + + await updateAuthUI(); + await populateNotebooks(); +} + +// --- Event handlers --- + +// Open rNotes sign-in +document.getElementById('openSigninBtn').addEventListener('click', () => { + const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST; + chrome.tabs.create({ url: `${host}/auth/signin?extension=true` }); +}); + +// Save token +document.getElementById('saveTokenBtn').addEventListener('click', async () => { + const tokenInput = document.getElementById('tokenInput').value.trim(); + + if (!tokenInput) { + showStatus('Please paste a token', 'error'); + return; + } + + const claims = decodeToken(tokenInput); + if (!claims) { + showStatus('Invalid or expired token', 'error'); + return; + } + + await chrome.storage.local.set({ encryptid_token: tokenInput }); + document.getElementById('tokenInput').value = ''; + + showStatus(`Signed in as ${claims.username || claims.sub}`, 'success'); + await updateAuthUI(); + await populateNotebooks(); +}); + +// Logout +document.getElementById('logoutBtn').addEventListener('click', async () => { + await chrome.storage.local.remove(['encryptid_token']); + showStatus('Signed out', 'success'); + await updateAuthUI(); +}); + +// Save settings +document.getElementById('saveBtn').addEventListener('click', async () => { + const host = document.getElementById('host').value.trim().replace(/\/+$/, ''); + const notebookId = document.getElementById('defaultNotebook').value; + + await chrome.storage.sync.set({ rnotesHost: host || DEFAULT_HOST }); + await chrome.storage.local.set({ lastNotebookId: notebookId }); + + showStatus('Settings saved', 'success'); +}); + +// Test connection +document.getElementById('testBtn').addEventListener('click', async () => { + const host = document.getElementById('host').value.trim().replace(/\/+$/, '') || DEFAULT_HOST; + const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); + + try { + const headers = {}; + if (encryptid_token) { + headers['Authorization'] = `Bearer ${encryptid_token}`; + } + + const response = await fetch(`${host}/api/notebooks`, { headers }); + + if (response.ok) { + const data = await response.json(); + showStatus(`Connected! Found ${data.length || 0} notebooks.`, 'success'); + } else if (response.status === 401) { + showStatus('Connected but not authenticated. Sign in first.', 'error'); + } else { + showStatus(`Connection failed: ${response.status}`, 'error'); + } + } catch (err) { + showStatus(`Cannot connect: ${err.message}`, 'error'); + } +}); + +// Default notebook change +document.getElementById('defaultNotebook').addEventListener('change', async (e) => { + await chrome.storage.local.set({ lastNotebookId: e.target.value }); +}); + +// Init +document.addEventListener('DOMContentLoaded', loadSettings); diff --git a/browser-extension/popup.html b/browser-extension/popup.html new file mode 100644 index 0000000..d0db5ac --- /dev/null +++ b/browser-extension/popup.html @@ -0,0 +1,223 @@ + + + + + + + +
+ rNotes Clipper + ... +
+ + + +
+
Loading...
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + + + + diff --git a/browser-extension/popup.js b/browser-extension/popup.js new file mode 100644 index 0000000..c0fa35d --- /dev/null +++ b/browser-extension/popup.js @@ -0,0 +1,269 @@ +const DEFAULT_HOST = 'https://rnotes.online'; + +let currentTab = null; +let selectedText = ''; +let selectedHtml = ''; + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rnotesHost']); + return { + host: result.rnotesHost || DEFAULT_HOST, + }; +} + +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])); + // Check expiry + if (payload.exp && payload.exp * 1000 < Date.now()) { + return null; // expired + } + 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(); + + const body = { + title: data.title, + content: data.content, + type: data.type || 'CLIP', + url: data.url, + }; + + const notebookId = document.getElementById('notebook').value; + if (notebookId) body.notebookId = notebookId; + + const tags = parseTags(document.getElementById('tags').value); + if (tags.length > 0) body.tags = tags; + + const response = await fetch(`${settings.host}/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(); + + const response = await fetch(`${settings.host}/api/notebooks`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data) ? data : []; +} + +// --- UI --- + +async function populateNotebooks() { + const select = document.getElementById('notebook'); + try { + const notebooks = await fetchNotebooks(); + // Keep the "No notebook" option + for (const nb of notebooks) { + const option = document.createElement('option'); + option.value = nb.id; + option.textContent = nb.title; + select.appendChild(option); + } + + // Restore last used notebook + const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); + if (lastNotebookId) { + select.value = lastNotebookId; + } + } catch (err) { + console.error('Failed to load notebooks:', err); + } +} + +// Save last used notebook when changed +function setupNotebookMemory() { + document.getElementById('notebook').addEventListener('change', (e) => { + chrome.storage.local.set({ lastNotebookId: e.target.value }); + }); +} + +async function init() { + // Get current tab + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + currentTab = tab; + + // Display page info + document.getElementById('pageTitle').textContent = tab.title || 'Untitled'; + document.getElementById('pageUrl').textContent = tab.url || ''; + + // Check auth + const token = await getToken(); + const claims = token ? decodeToken(token) : null; + + if (!claims) { + document.getElementById('userStatus').textContent = 'Not signed in'; + document.getElementById('userStatus').classList.add('not-authed'); + document.getElementById('authWarning').style.display = 'block'; + return; + } + + document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated'; + document.getElementById('authWarning').style.display = 'none'; + + // Enable buttons + document.getElementById('clipPageBtn').disabled = false; + + // Load notebooks + await populateNotebooks(); + setupNotebookMemory(); + + // Detect text selection + 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) { + // Can't access some pages (chrome://, etc.) + 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 { + // Get page HTML content + let pageContent = ''; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: currentTab.id }, + func: () => document.body.innerHTML, + }); + pageContent = result?.result || ''; + } catch { + // Fallback: just use URL as content + pageContent = `

Clipped from ${currentTab.url}

`; + } + + const note = await createNote({ + title: currentTab.title || 'Untitled Clip', + content: pageContent, + type: 'CLIP', + url: currentTab.url, + }); + + showStatus(`Clipped! Note saved.`, 'success'); + + // Notify background worker + chrome.runtime.sendMessage({ + type: 'notify', + title: 'Page Clipped', + message: `"${currentTab.title}" saved to rNotes`, + }); + } 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 || `

${selectedText}

`; + const note = await createNote({ + title: `Selection from ${currentTab.title || 'page'}`, + content: content, + type: 'CLIP', + url: currentTab.url, + }); + + showStatus(`Selection clipped!`, 'success'); + + chrome.runtime.sendMessage({ + type: 'notify', + title: 'Selection Clipped', + message: `Saved to rNotes`, + }); + } catch (err) { + showStatus(`Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +document.getElementById('optionsLink').addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +document.getElementById('openSettings')?.addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +// Init on load +document.addEventListener('DOMContentLoaded', init); diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 435c8d6..7b4c172 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -9,26 +9,28 @@ function SignInForm() { const router = useRouter(); const searchParams = useSearchParams(); const returnUrl = searchParams.get('returnUrl') || '/'; + const isExtension = searchParams.get('extension') === 'true'; const { isAuthenticated, loading: authLoading, login, register } = useEncryptID(); const [mode, setMode] = useState<'signin' | 'register'>('signin'); const [username, setUsername] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); + const [tokenCopied, setTokenCopied] = useState(false); - // Redirect if already authenticated + // Redirect if already authenticated (skip if extension mode — show token instead) useEffect(() => { - if (isAuthenticated && !authLoading) { + if (isAuthenticated && !authLoading && !isExtension) { router.push(returnUrl); } - }, [isAuthenticated, authLoading, router, returnUrl]); + }, [isAuthenticated, authLoading, router, returnUrl, isExtension]); const handleSignIn = async () => { setError(''); setBusy(true); try { await login(); - router.push(returnUrl); + if (!isExtension) router.push(returnUrl); } catch (err) { setError(err instanceof Error ? err.message : 'Sign in failed. Make sure you have a registered passkey.'); } finally { @@ -45,7 +47,7 @@ function SignInForm() { setBusy(true); try { await register(username.trim()); - router.push(returnUrl); + if (!isExtension) router.push(returnUrl); } catch (err) { setError(err instanceof Error ? err.message : 'Registration failed.'); } finally { @@ -162,6 +164,33 @@ function SignInForm() { )} + {/* Extension token display */} + {isExtension && isAuthenticated && ( +
+

Extension Token

+

+ Copy this token and paste it in the rNotes Web Clipper extension settings. +

+