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 router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const returnUrl = searchParams.get('returnUrl') || '/';
|
const returnUrl = searchParams.get('returnUrl') || '/';
|
||||||
|
const isExtension = searchParams.get('extension') === 'true';
|
||||||
const { isAuthenticated, loading: authLoading, login, register } = useEncryptID();
|
const { isAuthenticated, loading: authLoading, login, register } = useEncryptID();
|
||||||
|
|
||||||
const [mode, setMode] = useState<'signin' | 'register'>('signin');
|
const [mode, setMode] = useState<'signin' | 'register'>('signin');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && !authLoading) {
|
if (isAuthenticated && !authLoading && !isExtension) {
|
||||||
router.push(returnUrl);
|
router.push(returnUrl);
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, authLoading, router, returnUrl]);
|
}, [isAuthenticated, authLoading, router, returnUrl, isExtension]);
|
||||||
|
|
||||||
const handleSignIn = async () => {
|
const handleSignIn = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await login();
|
await login();
|
||||||
router.push(returnUrl);
|
if (!isExtension) router.push(returnUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Sign in failed. Make sure you have a registered passkey.');
|
setError(err instanceof Error ? err.message : 'Sign in failed. Make sure you have a registered passkey.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -45,7 +47,7 @@ function SignInForm() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await register(username.trim());
|
await register(username.trim());
|
||||||
router.push(returnUrl);
|
if (!isExtension) router.push(returnUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Registration failed.');
|
setError(err instanceof Error ? err.message : 'Registration failed.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -162,6 +164,33 @@ function SignInForm() {
|
||||||
)}
|
)}
|
||||||
</button>
|
</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">
|
<p className="text-center text-xs text-slate-500 mt-6">
|
||||||
Powered by EncryptID — passwordless, decentralized identity
|
Powered by EncryptID — passwordless, decentralized identity
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue