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:
parent
49afc5db6e
commit
32475bdf34
|
|
@ -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 |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 — passwordless, decentralized identity
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Reference in New Issue