feat: add rNotes Web Clipper browser extension (TASK-6)

Chrome extension (Manifest V3) forked from PKMN Quick Capture:
- Clip pages, text selections, links, and images to rNotes
- Notebook selection dropdown with remembered last-used
- Tags input for organizing clips
- Context menu integration (right-click to save)
- EncryptID auth via token paste flow
- Image upload through /api/uploads then IMAGE note creation
- Amber/orange theme matching rNotes design

Also updates signin page to show extension token when
?extension=true query param is present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 15:17:19 -07:00
parent 49afc5db6e
commit 32475bdf34
10 changed files with 1220 additions and 5 deletions

View File

@ -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 = `<p>Clipped from <a href="${tab.url}">${tab.url}</a></p>`;
}
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: `<p><a href="${linkUrl}">${linkText}</a></p><p>Found on: <a href="${tab.url}">${tab.title}</a></p>`,
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: `<p><img src="${upload.url}" alt="Clipped image" /></p><p>Source: <a href="${tab.url}">${tab.title}</a></p>`,
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 = `<p>${info.selectionText || ''}</p>`;
}
if (!content && info.selectionText) {
content = `<p>${info.selectionText}</p>`;
}
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);
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

View File

@ -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
}
}

View File

@ -0,0 +1,231 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 400px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
padding: 16px;
font-size: 13px;
}
h2 {
font-size: 16px;
color: #f59e0b;
margin-bottom: 16px;
font-weight: 700;
}
.section {
margin-bottom: 16px;
padding: 12px;
background: #171717;
border-radius: 8px;
border: 1px solid #262626;
}
.section h3 {
font-size: 13px;
color: #d4d4d4;
margin-bottom: 10px;
font-weight: 600;
}
.field {
margin-bottom: 10px;
}
.field:last-child {
margin-bottom: 0;
}
label {
display: block;
font-size: 11px;
color: #a3a3a3;
margin-bottom: 4px;
font-weight: 500;
}
input[type="text"], input[type="password"], textarea {
width: 100%;
padding: 7px 10px;
background: #0a0a0a;
border: 1px solid #404040;
border-radius: 4px;
color: #e5e5e5;
font-size: 12px;
font-family: inherit;
outline: none;
}
input:focus, textarea:focus {
border-color: #f59e0b;
}
textarea {
resize: vertical;
min-height: 60px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 11px;
}
select {
width: 100%;
padding: 7px 10px;
background: #0a0a0a;
border: 1px solid #404040;
border-radius: 4px;
color: #e5e5e5;
font-size: 12px;
outline: none;
}
.help {
font-size: 10px;
color: #737373;
margin-top: 3px;
}
.auth-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 4px;
margin-bottom: 10px;
font-size: 12px;
}
.auth-status.authed {
background: #052e16;
border: 1px solid #166534;
color: #4ade80;
}
.auth-status.not-authed {
background: #451a03;
border: 1px solid #78350f;
color: #fbbf24;
}
.btn-row {
display: flex;
gap: 8px;
margin-top: 10px;
}
button {
padding: 7px 14px;
border: none;
border-radius: 5px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover { opacity: 0.85; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary {
background: #f59e0b;
color: #0a0a0a;
}
.btn-secondary {
background: #262626;
color: #e5e5e5;
border: 1px solid #404040;
}
.btn-danger {
background: #991b1b;
color: #fca5a5;
}
.btn-small {
padding: 5px 10px;
font-size: 11px;
}
.status {
margin-top: 12px;
padding: 8px 10px;
border-radius: 4px;
font-size: 12px;
display: none;
}
.status.success {
background: #052e16;
border: 1px solid #166534;
color: #4ade80;
display: block;
}
.status.error {
background: #450a0a;
border: 1px solid #991b1b;
color: #fca5a5;
display: block;
}
</style>
</head>
<body>
<h2>rNotes Web Clipper Settings</h2>
<!-- Connection -->
<div class="section">
<h3>Connection</h3>
<div class="field">
<label for="host">rNotes URL</label>
<input type="text" id="host" value="https://rnotes.online" />
<div class="help">The URL of your rNotes instance</div>
</div>
</div>
<!-- Authentication -->
<div class="section">
<h3>Authentication</h3>
<div id="authStatus" class="auth-status not-authed">
Not signed in
</div>
<div id="loginSection">
<div class="field">
<label>Step 1: Sign in on rNotes</label>
<button class="btn-secondary btn-small" id="openSigninBtn">Open rNotes Sign-in</button>
<div class="help">Opens rNotes in a new tab. Sign in with your passkey.</div>
</div>
<div class="field">
<label for="tokenInput">Step 2: Paste your token</label>
<textarea id="tokenInput" placeholder="Paste your token from the rNotes sign-in page here..."></textarea>
<div class="help">After signing in, copy the extension token and paste it here.</div>
</div>
<div class="btn-row">
<button class="btn-primary" id="saveTokenBtn">Save Token</button>
</div>
</div>
<div id="loggedInSection" style="display: none;">
<button class="btn-danger btn-small" id="logoutBtn">Logout</button>
</div>
</div>
<!-- Default Notebook -->
<div class="section">
<h3>Default Notebook</h3>
<div class="field">
<label for="defaultNotebook">Save clips to</label>
<select id="defaultNotebook">
<option value="">No default (choose each time)</option>
</select>
<div class="help">Pre-selected notebook when clipping</div>
</div>
</div>
<!-- Actions -->
<div class="btn-row" style="justify-content: flex-end;">
<button class="btn-secondary" id="testBtn">Test Connection</button>
<button class="btn-primary" id="saveBtn">Save Settings</button>
</div>
<div id="status" class="status"></div>
<script src="options.js"></script>
</body>
</html>

View File

@ -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);

View File

@ -0,0 +1,223 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 340px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
font-size: 13px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #171717;
border-bottom: 1px solid #262626;
}
.header .brand {
font-weight: 700;
font-size: 14px;
color: #f59e0b;
}
.header .user {
font-size: 11px;
color: #a3a3a3;
}
.header .user.not-authed {
color: #ef4444;
}
.auth-warning {
padding: 10px 14px;
background: #451a03;
border-bottom: 1px solid #78350f;
text-align: center;
font-size: 12px;
color: #fbbf24;
}
.auth-warning a {
color: #f59e0b;
text-decoration: underline;
cursor: pointer;
}
.current-page {
padding: 10px 14px;
border-bottom: 1px solid #262626;
}
.current-page .title {
font-weight: 600;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.current-page .url {
font-size: 11px;
color: #737373;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
label {
display: block;
font-size: 11px;
color: #a3a3a3;
margin-bottom: 3px;
font-weight: 500;
}
select, input[type="text"] {
width: 100%;
padding: 6px 8px;
background: #171717;
border: 1px solid #404040;
border-radius: 4px;
color: #e5e5e5;
font-size: 12px;
outline: none;
}
select:focus, input[type="text"]:focus {
border-color: #f59e0b;
}
.actions {
padding: 0 14px 10px;
display: flex;
gap: 8px;
}
button {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: opacity 0.15s;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
button:hover:not(:disabled) {
opacity: 0.85;
}
.btn-primary {
background: #f59e0b;
color: #0a0a0a;
}
.btn-secondary {
background: #262626;
color: #e5e5e5;
border: 1px solid #404040;
}
.status {
margin: 0 14px 10px;
padding: 8px 10px;
border-radius: 4px;
font-size: 12px;
display: none;
}
.status.success {
background: #052e16;
border: 1px solid #166534;
color: #4ade80;
display: block;
}
.status.error {
background: #450a0a;
border: 1px solid #991b1b;
color: #fca5a5;
display: block;
}
.status.loading {
background: #172554;
border: 1px solid #1e40af;
color: #93c5fd;
display: block;
}
.footer {
padding: 8px 14px;
border-top: 1px solid #262626;
text-align: center;
}
.footer a {
color: #737373;
text-decoration: none;
font-size: 11px;
}
.footer a:hover {
color: #f59e0b;
}
</style>
</head>
<body>
<div class="header">
<span class="brand">rNotes Clipper</span>
<span class="user" id="userStatus">...</span>
</div>
<div id="authWarning" class="auth-warning" style="display: none;">
Sign in to clip pages. <a id="openSettings">Open Settings</a>
</div>
<div class="current-page">
<div class="title" id="pageTitle">Loading...</div>
<div class="url" id="pageUrl"></div>
</div>
<div class="controls">
<div>
<label for="notebook">Notebook</label>
<select id="notebook">
<option value="">No notebook</option>
</select>
</div>
<div>
<label for="tags">Tags (comma-separated)</label>
<input type="text" id="tags" placeholder="web-clip, research, ..." />
</div>
</div>
<div class="actions">
<button class="btn-primary" id="clipPageBtn" disabled>
<span>+</span> Clip Page
</button>
<button class="btn-secondary" id="clipSelectionBtn" disabled>
<span>T</span> Clip Selection
</button>
</div>
<div id="status" class="status"></div>
<div class="footer">
<a href="#" id="optionsLink">Settings</a>
</div>
<script src="popup.js"></script>
</body>
</html>

269
browser-extension/popup.js Normal file
View File

@ -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 = `<p>Clipped from <a href="${currentTab.url}">${currentTab.url}</a></p>`;
}
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 || `<p>${selectedText}</p>`;
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);

View File

@ -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() {
)}
</button>
{/* Extension token display */}
{isExtension && isAuthenticated && (
<div className="mt-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<h3 className="text-sm font-semibold text-amber-400 mb-2">Extension Token</h3>
<p className="text-xs text-slate-400 mb-3">
Copy this token and paste it in the rNotes Web Clipper extension settings.
</p>
<textarea
readOnly
value={typeof window !== 'undefined' ? localStorage.getItem('encryptid_token') || '' : ''}
className="w-full h-20 px-3 py-2 bg-slate-900 border border-slate-700 rounded text-xs text-slate-300 font-mono resize-none focus:outline-none"
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
/>
<button
onClick={() => {
const token = localStorage.getItem('encryptid_token') || '';
navigator.clipboard.writeText(token);
setTokenCopied(true);
setTimeout(() => setTokenCopied(false), 2000);
}}
className="mt-2 w-full py-2 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-lg text-sm transition-colors"
>
{tokenCopied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
)}
<p className="text-center text-xs text-slate-500 mt-6">
Powered by EncryptID &mdash; passwordless, decentralized identity
</p>