feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-05-24 18:57:33 +00:00
parent e2672cd2c2
commit e881004c77
63 changed files with 5729 additions and 563 deletions

View File

@@ -0,0 +1,46 @@
# Memento Web Clipper — extension Chrome
Clipper web avec **panneau latéral** : le panneau reste ouvert pendant que vous surlignez du texte sur la page.
## Installation (dev)
1. Chrome → `chrome://extensions`
2. **Mode développeur****Charger lextension non empaquetée** → dossier `memento-note/extension`
3. Épingle licône Momento
> Chrome **114+** requis (Side Panel API).
## Instance Momento
- **Dev** : icône ⚙ → URL (`http://localhost:3000` ou IP LAN) → **Appliquer & reconnecter**
- Connectez-vous sur **la même URL** dans Chrome (Google OAuth)
- **Production (build Store)** : mettre `ALLOW_INSTANCE_CONFIG = false` dans `sidepanel.js` → URL `https://memento-note.com` en dur, réglages masqués
## Utilisation
1. Ouvrez une page web normale (pas `chrome://`)
2. Cliquez licône Momento → panneau latéral
3. Choisissez le **carnet** (liste hiérarchique)
4. Optionnel : surlignez du texte → **Clipper la sélection** (bouton sky)
5. Ou **Clipper cette page** (article complet + IA)
6. Ou **Enregistrer le lien seul**
7. **Aperçu** : titre éditable, résumé, extrait, temps de lecture → **Enregistrer dans Momento**
## Dépannage
| Problème | Solution |
|----------|----------|
| Carnets vides / 401 | **Ouvrir Momento ↗** sur la même URL, connectez-vous |
| `localhost` vs `127.0.0.1` | Utilisez **toujours la même** URL partout (cookies session) |
| Pas de sélection | Rechargez la page après install extension ; surlignez sur la page, pas dans le panneau |
| Page Chrome système | Impossible — ouvrez un site http(s) normal |
## Persan / RTL
Détection automatique `dir` / `lang` (ex. BBC Persian), aperçu RTL avec Vazirmatn.
## APIs
- `GET /api/clip/notebooks`
- `POST /api/clip/analyze`
- `POST /api/clip/save`

View File

@@ -0,0 +1,8 @@
/** Service worker — ouvre le panneau latéral au clic sur licône. */
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
})
chrome.runtime.onStartup.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
})

View File

@@ -0,0 +1,207 @@
/**
* Content script Momento — sélection live, surlignage, communication avec le side panel.
* Injecté automatiquement sur http(s) ; ré-injecté à la demande si longlet était déjà ouvert.
*/
;(function initMementoClipperContent() {
if (globalThis.__mementoClipperContent) return
globalThis.__mementoClipperContent = true
const HIGHLIGHT_ID = 'memento-clipper-highlight-root'
const BANNER_ID = 'memento-clipper-banner-root'
const STYLE_ID = 'memento-clipper-styles'
let pickMode = false
let debounceTimer = null
function getSelectionText() {
return window.getSelection()?.toString().trim() || ''
}
function getPageMeta() {
const dir =
document.documentElement.getAttribute('dir') ||
document.body?.getAttribute('dir') ||
''
const lang = (
document.documentElement.getAttribute('lang') ||
document.body?.getAttribute('lang') ||
''
).split('-')[0]
return {
text: getSelectionText(),
dir,
lang,
url: location.href,
title: document.title,
}
}
function broadcastSelection() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
const payload = { type: 'SELECTION_CHANGED', ...getPageMeta() }
try {
chrome.runtime.sendMessage(payload).catch(() => {})
} catch {
/* ignore */
}
if (pickMode) paintHighlight()
}, 80)
}
function removeHighlight() {
document.getElementById(HIGHLIGHT_ID)?.remove()
}
function paintHighlight() {
removeHighlight()
const sel = window.getSelection()
if (!sel || sel.isCollapsed || !sel.rangeCount) return
let range
try {
range = sel.getRangeAt(0)
} catch {
return
}
const host = document.createElement('div')
host.id = HIGHLIGHT_ID
host.setAttribute('aria-hidden', 'true')
host.style.cssText =
'position:fixed;inset:0;pointer-events:none;z-index:2147483644;overflow:hidden;'
for (const rect of range.getClientRects()) {
if (rect.width < 2 || rect.height < 2) continue
const box = document.createElement('div')
box.style.cssText = [
'position:fixed',
`left:${rect.left - 2}px`,
`top:${rect.top - 1}px`,
`width:${rect.width + 4}px`,
`height:${rect.height + 2}px`,
'background:rgba(164,113,72,0.28)',
'border-radius:3px',
'box-shadow:0 0 0 1px rgba(164,113,72,0.35)',
'transition:opacity 0.15s ease',
].join(';')
host.appendChild(box)
}
if (host.childNodes.length) document.documentElement.appendChild(host)
}
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = `
html.memento-clipper-pick ::selection {
background: rgba(164, 113, 72, 0.45) !important;
color: inherit !important;
}
html.memento-clipper-pick {
scroll-behavior: auto;
}
`
document.documentElement.appendChild(style)
}
function removeBanner() {
document.getElementById(BANNER_ID)?.remove()
}
function ensureBanner() {
if (document.getElementById(BANNER_ID)) return
const host = document.createElement('div')
host.id = BANNER_ID
host.style.cssText =
'all:initial;position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;pointer-events:none;font-family:Inter,system-ui,sans-serif;'
const shadow = host.attachShadow({ mode: 'open' })
shadow.innerHTML = `
<style>
.pill {
display: flex; align-items: center; gap: 10px;
padding: 10px 18px; border-radius: 999px;
background: #1c1c1c; color: #faf9f5;
box-shadow: 0 12px 32px rgba(0,0,0,0.22);
font-size: 12px; font-weight: 600;
letter-spacing: 0.02em;
animation: slideIn 0.35s cubic-bezier(0.22,1,0.36,1);
}
.logo {
width: 22px; height: 22px; border-radius: 7px;
background: #faf9f5; color: #1c1c1c;
display: flex; align-items: center; justify-content: center;
font-family: Georgia, serif; font-weight: 900; font-size: 12px;
}
.dot {
width: 8px; height: 8px; border-radius: 50%;
background: #a47148; animation: pulse 1.2s ease infinite;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
</style>
<div class="pill">
<span class="logo">M</span>
<span class="dot"></span>
<span>Surlignez le texte à clipper</span>
</div>
`
document.documentElement.appendChild(host)
}
function setPickMode(enabled) {
pickMode = !!enabled
ensureStyles()
if (pickMode) {
document.documentElement.classList.add('memento-clipper-pick')
ensureBanner()
paintHighlight()
} else {
document.documentElement.classList.remove('memento-clipper-pick')
removeBanner()
removeHighlight()
}
}
function onScrollOrResize() {
if (pickMode) paintHighlight()
}
document.addEventListener('selectionchange', broadcastSelection)
document.addEventListener('mouseup', broadcastSelection)
document.addEventListener('keyup', broadcastSelection)
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true })
window.addEventListener('resize', onScrollOrResize, { passive: true })
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'PING') {
sendResponse({ ok: true })
return true
}
if (message?.type === 'GET_CONTEXT') {
sendResponse({
html: document.documentElement.outerHTML,
...getPageMeta(),
})
return true
}
if (message?.type === 'SET_PICK_MODE') {
setPickMode(!!message.enabled)
sendResponse({ ok: true, pickMode })
return true
}
return false
})
broadcastSelection()
})()

View File

@@ -0,0 +1,31 @@
{
"manifest_version": 3,
"name": "Memento Web Clipper",
"version": "0.3.0",
"description": "Enregistrez des pages et des sélections dans Momento avec résumé IA.",
"permissions": ["activeTab", "scripting", "storage", "sidePanel", "tabs"],
"host_permissions": [
"http://localhost:3000/*",
"http://127.0.0.1:3000/*",
"https://memento-note.com/*",
"http://*/*",
"https://*/*"
],
"background": {
"service_worker": "background.js"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"action": {
"default_title": "Momento Web Clipper"
}
}

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Genere extension/_locales/<lang>/messages.json depuis i18n/translations.json
* Usage: node scripts/build-extension-locales.mjs
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const extRoot = path.resolve(__dirname, '..')
const srcPath = path.join(extRoot, 'i18n', 'translations.json')
const outRoot = path.join(extRoot, '_locales')
const { version, strings } = JSON.parse(fs.readFileSync(srcPath, 'utf8'))
const langs = Object.keys(strings)
for (const lang of langs) {
const dir = path.join(outRoot, lang)
fs.mkdirSync(dir, { recursive: true })
const messages = {}
for (const [key, def] of Object.entries(strings[lang])) {
const entry = { message: def.message.replace(/\{version\}/g, version) }
if (def.description) entry.description = def.description
if (def.placeholders) entry.placeholders = def.placeholders
messages[key] = entry
}
fs.writeFileSync(path.join(dir, 'messages.json'), JSON.stringify(messages, null, 2) + '\n')
}
console.log(`Generated ${langs.length} locales in ${outRoot}`)

View File

@@ -0,0 +1,490 @@
:root {
--ink: #1c1c1c;
--paper: #faf9f5;
--card: #ffffff;
--muted: #6b7280;
--border: #e8e4dc;
--accent: #a47148;
--accent-soft: rgba(164, 113, 72, 0.12);
--accent-glow: rgba(164, 113, 72, 0.35);
--success: #10b981;
--danger: #ef4444;
--shadow: 0 18px 40px rgba(28, 28, 28, 0.08);
--radius: 14px;
--radius-sm: 10px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 13px;
color: var(--ink);
background: var(--paper);
}
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--paper);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
background: linear-gradient(180deg, #fff 0%, #fcfcfa 100%);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-logo {
width: 34px; height: 34px; border-radius: 11px;
background: var(--ink); color: #faf9f5;
display: flex; align-items: center; justify-content: center;
font-family: Georgia, 'Times New Roman', serif;
font-weight: 900; font-size: 16px;
box-shadow: 0 4px 14px rgba(28, 28, 28, 0.18);
}
.brand-text { line-height: 1.1; }
.brand-name {
display: block; font-size: 14px; font-weight: 700;
font-family: Georgia, 'Times New Roman', serif;
}
.brand-sub {
display: block; font-size: 9px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--accent); font-weight: 700;
}
.icon-btn {
width: 34px; height: 34px; border-radius: 10px;
border: 1px solid var(--border); background: #fff;
color: var(--muted); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.icon-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.conn-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 9px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
.conn-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #10b981;
}
.settings-panel {
padding: 14px 18px;
background: #fff;
border-bottom: 1px solid var(--border);
}
.settings-panel[hidden] { display: none !important; }
.settings-hint {
margin: 8px 0 0;
font-size: 11px;
color: var(--muted);
line-height: 1.5;
}
.settings-hint code {
font-size: 10px;
background: var(--paper);
padding: 1px 4px;
border-radius: 4px;
}
.settings-status {
margin: 10px 0 0;
font-size: 11px;
line-height: 1.45;
}
.settings-status.is-ok { color: #059669; }
.settings-status.is-error { color: #dc2626; }
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.preset-btn {
border: 1px solid var(--border);
background: var(--paper);
border-radius: 999px;
padding: 6px 10px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
color: var(--muted);
}
.preset-btn:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field span {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em;
color: var(--muted); font-weight: 700;
}
input[type="url"],
input[type="text"],
.notebook-select {
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--paper);
font-family: inherit; font-size: 12px;
}
input[type="url"]:focus,
input[type="text"]:focus,
.notebook-select:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.notebook-select {
background: #fff;
font-weight: 600;
cursor: pointer;
}
.main {
flex: 1;
padding: 16px 18px 20px;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.main > .actions {
margin-top: auto;
}
.footer {
padding: 10px 18px 14px;
border-top: 1px solid var(--border);
background: #fff;
text-align: center;
}
.footer-meta { font-size: 9px; color: #9ca3af; letter-spacing: 0.06em; }
.label {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); font-weight: 700; margin-bottom: 8px; display: block;
}
.auth-hint {
padding: 12px 14px;
border-radius: var(--radius);
background: #fffbeb;
border: 1px solid #fde68a;
font-size: 11px;
color: #92400e;
line-height: 1.5;
}
.page-card {
padding: 14px; border: 1px solid var(--border); border-radius: var(--radius);
background: #fff;
box-shadow: 0 1px 0 rgba(255,255,255,0.8) inset;
}
.page-card .sub {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--muted); font-weight: 700; display: block; margin-bottom: 8px;
}
.page-row { display: flex; gap: 10px; align-items: flex-start; min-width: 0; }
.page-row img {
width: 20px; height: 20px; border-radius: 5px;
flex-shrink: 0; margin-top: 2px;
}
.page-text { min-width: 0; flex: 1; }
.page-row .title {
font-size: 12px; font-weight: 700; line-height: 1.45;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
unicode-bidi: plaintext;
}
.page-row .url {
font-size: 10px; color: var(--muted); margin-top: 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
direction: ltr; text-align: left;
}
.text-rtl {
direction: rtl;
text-align: right;
font-family: 'Vazirmatn', 'Inter', sans-serif;
unicode-bidi: plaintext;
}
.selection-panel {
border-radius: var(--radius);
border: 1px solid var(--border);
background: #fff;
overflow: hidden;
min-height: 140px;
display: flex;
flex-direction: column;
}
.selection-panel.has-text {
border-color: #bae6fd;
background: rgba(14, 165, 233, 0.05);
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12);
}
.selection-hint {
padding: 16px;
border: 1px dashed var(--border);
border-radius: var(--radius);
text-align: center;
background: #fff;
}
.selection-hint p {
margin: 0;
font-size: 11px;
color: var(--muted);
line-height: 1.55;
}
.selection-head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px;
background: linear-gradient(180deg, #fff 0%, #fdfcfa 100%);
border-bottom: 1px solid var(--border);
}
.selection-head .status {
display: flex; align-items: center; gap: 8px;
font-size: 10px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--muted);
}
.selection-head .status.live { color: #0284c7; }
.selection-head .count {
font-size: 10px; font-weight: 700; color: var(--muted);
background: var(--paper); padding: 4px 8px; border-radius: 999px;
}
.selection-head .count.active { color: var(--accent); background: var(--accent-soft); }
.selection-body {
flex: 1;
padding: 14px;
font-size: 13px;
line-height: 1.75;
color: rgba(28, 28, 28, 0.88);
max-height: 220px;
overflow-y: auto;
unicode-bidi: plaintext;
border-inline-start: 3px solid transparent;
}
.selection-panel.has-text .selection-body {
border-inline-start-color: #38bdf8;
padding-inline-start: 16px;
font-style: italic;
font-size: 12px;
max-height: 150px;
}
.pulse-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
animation: pulse 1.4s ease infinite;
}
.pulse-dot.sky { background: #0ea5e9; }
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.85)} }
.clear-btn {
border: none; background: none; font-size: 10px;
color: var(--muted); cursor: pointer; font-weight: 600;
padding: 4px 6px; border-radius: 6px;
}
.clear-btn:hover { color: var(--ink); background: var(--paper); }
.actions {
display: flex; flex-direction: column; gap: 10px;
margin-top: auto; padding-top: 6px;
}
.btn {
padding: 14px 16px; border-radius: var(--radius); border: none; cursor: pointer;
font-weight: 700; font-size: 10px; text-transform: uppercase;
letter-spacing: 0.1em;
display: flex; align-items: center; justify-content: center; gap: 8px;
transition: transform 0.12s ease, opacity 0.12s ease, filter 0.12s ease;
}
.btn:active { transform: scale(0.98); }
.btn:disabled {
opacity: 0.42; cursor: not-allowed; transform: none;
box-shadow: none !important;
}
.btn-primary {
background: var(--ink); color: #fff;
box-shadow: 0 10px 24px rgba(28, 28, 28, 0.18);
}
.btn-primary:hover:not(:disabled) { opacity: 0.94; }
.btn-sky {
background: #0284c7;
color: #fff;
box-shadow: 0 10px 22px rgba(2, 132, 199, 0.22);
}
.btn-sky:hover:not(:disabled) { background: #0369a1; }
.btn-secondary {
background: #f3f4f6;
color: #374151;
box-shadow: none;
}
.btn-secondary:hover:not(:disabled) { background: #e5e7eb; }
.btn-sm {
padding: 10px 12px;
font-size: 10px;
}
.btn-danger { background: var(--danger); color: #fff; }
.btn-link.link-only {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-link {
background: none; border: none; color: var(--muted); font-size: 11px;
cursor: pointer; padding: 8px; font-weight: 500;
}
.btn-link:hover { color: var(--ink); text-decoration: underline; }
.btn-icon { width: 14px; height: 14px; display: inline-flex; }
.center-state {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; gap: 14px; padding: 32px 12px;
min-height: 280px;
}
.spinner-wrap { position: relative; width: 52px; height: 52px; }
.spinner-ring {
position: absolute; inset: 0; border-radius: 50%;
border: 1px solid var(--border); animation: ping 1.2s ease infinite;
}
@keyframes ping { 0%{transform:scale(1);opacity:.6} 100%{transform:scale(1.35);opacity:0} }
.spinner {
position: absolute; inset: 6px;
border: 3px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.state-title {
font-size: 10px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.14em; color: var(--muted);
}
.state-sub { font-size: 15px; font-weight: 700; color: var(--ink); }
.state-detail {
font-size: 11px; color: var(--muted); max-width: 280px;
line-height: 1.55; margin: 0 auto;
}
.success-icon, .error-icon {
width: 58px; height: 58px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 26px; font-weight: 700;
}
.success-icon {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.25); color: var(--success);
}
.error-icon { background: #fef2f2; color: var(--danger); }
.badge-ok {
display: inline-block; margin-bottom: 8px;
font-size: 9px; background: rgba(16, 185, 129, 0.12);
color: #059669; font-weight: 800; padding: 3px 8px; border-radius: 6px;
text-transform: uppercase; letter-spacing: 0.1em;
}
.note-title {
font-size: 15px; font-weight: 700;
font-family: Georgia, 'Times New Roman', serif;
line-height: 1.35; margin-top: 6px;
unicode-bidi: plaintext;
}
.tags {
display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
padding-top: 14px; border-top: 1px solid var(--border); margin-top: 10px;
width: 100%;
}
.tag-chip {
font-size: 9px; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--accent);
background: var(--accent-soft); border: 1px solid rgba(164, 113, 72, 0.2);
padding: 5px 10px; border-radius: 999px;
}
.restricted-note {
padding: 14px; border-radius: var(--radius);
background: #fef2f2; border: 1px solid #fecaca;
font-size: 11px; color: #991b1b; line-height: 1.5;
}
.confirm-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.summary-preview {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.55;
font-style: italic;
}
.excerpt-preview {
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: #fff;
font-size: 12px;
line-height: 1.65;
max-height: 150px;
overflow-y: auto;
unicode-bidi: plaintext;
}
.excerpt-label {
display: block;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
font-weight: 700;
margin-bottom: 8px;
}
.meta-row { margin-top: -4px; }
.reading-time {
font-size: 10px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.preview-tags { justify-content: flex-start; border-top: none; margin-top: 0; padding-top: 0; }

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Momento Web Clipper</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Vazirmatn:wght@400;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="sidepanel.css" />
</head>
<body>
<div id="app" class="shell">
<header class="header">
<div class="brand">
<div class="brand-logo">M</div>
<div class="brand-text">
<span class="brand-name">Momento</span>
<span class="brand-sub">Web Clipper</span>
</div>
</div>
<div class="header-right">
<div id="connBadge" class="conn-badge" hidden>
<span class="conn-dot"></span>
<span id="connLabel">Connecté</span>
</div>
<button type="button" id="settingsBtn" class="icon-btn" title="Instance Momento" aria-label="Instance Momento">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
</div>
</header>
<div id="settingsPanel" class="settings-panel" hidden>
<label class="field">
<span>URL de votre instance Momento</span>
<input id="baseUrl" type="text" spellcheck="false" placeholder="http://localhost:3000" />
</label>
<div class="preset-row">
<button type="button" class="preset-btn" data-url="https://memento-note.com">Production</button>
<button type="button" class="preset-btn" data-url="http://localhost:3000">localhost:3000</button>
<button type="button" class="preset-btn" data-url="http://127.0.0.1:3000">127.0.0.1:3000</button>
</div>
<div class="settings-actions">
<button type="button" id="applyInstanceBtn" class="btn btn-primary btn-sm">Appliquer &amp; reconnecter</button>
<button type="button" id="openLoginBtn" class="btn btn-secondary btn-sm">Ouvrir Momento ↗</button>
</div>
<p class="settings-hint">
Connectez-vous sur <strong>la même URL</strong> dans Chrome (Google OAuth). En dev, utilisez exactement
<code>http://localhost:3000</code> ou <code>http://127.0.0.1:3000</code> — pas un mélange des deux.
</p>
<p id="settingsStatus" class="settings-status" hidden></p>
</div>
<main id="screen" class="main"></main>
<footer class="footer">
<span class="footer-meta">Momento Web Clipper v0.3.0</span>
</footer>
</div>
<script src="sidepanel.js"></script>
</body>
</html>

View File

@@ -0,0 +1,703 @@
/** Mettre à false pour le build Chrome Web Store (URL production en dur). */
const ALLOW_INSTANCE_CONFIG = true
const DEFAULT_BASE = 'https://memento-note.com'
const STORAGE_KEYS = { baseUrl: 'memento_clipper_base_url', notebookId: 'memento_clipper_notebook_id' }
let state = 'idle'
let notebooks = []
let selectedNotebookId = ''
let pageUrl = ''
let pageTitle = ''
let pageDomain = ''
let pageFavicon = ''
let pageHtml = ''
let pageDir = 'ltr'
let pageLang = ''
let selectionText = ''
let pageRestricted = false
let lastNoteId = ''
let lastNoteUrl = ''
let successTitle = ''
let successTags = []
let errorMessage = ''
let activeTabId = null
let pendingClipType = 'page'
let analyzeResult = null
let editableTitle = ''
let connected = false
const els = {
screen: document.getElementById('screen'),
baseUrl: document.getElementById('baseUrl'),
settingsPanel: document.getElementById('settingsPanel'),
settingsBtn: document.getElementById('settingsBtn'),
connBadge: document.getElementById('connBadge'),
connLabel: document.getElementById('connLabel'),
settingsStatus: document.getElementById('settingsStatus'),
applyInstanceBtn: document.getElementById('applyInstanceBtn'),
openLoginBtn: document.getElementById('openLoginBtn'),
}
const ICON_SELECT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>'
const ICON_CLIP =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>'
const ICON_LINK =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
function apiBase() {
if (!ALLOW_INSTANCE_CONFIG) return DEFAULT_BASE
return (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
}
function isRestrictedUrl(url) {
return !url || /^(chrome|chrome-extension|edge|about|moz-extension|devtools):/i.test(url)
}
async function ensureApiPermission() {
const origin = `${apiBase()}/*`
const has = await chrome.permissions.contains({ origins: [origin] })
if (!has) {
const granted = await chrome.permissions.request({ origins: [origin] })
if (!granted) throw new Error('Autorisez laccès à votre instance Momento dans Chrome.')
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const RTL_CHAR = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
const LTR_CHAR = /[A-Za-z0-9]/
function detectTextDirection(text) {
const sample = String(text || '').replace(/\s+/g, '').slice(0, 3000)
if (!sample) return 'ltr'
let rtl = 0
let ltr = 0
for (const ch of sample) {
if (RTL_CHAR.test(ch)) rtl++
else if (LTR_CHAR.test(ch)) ltr++
}
if (rtl === 0) return 'ltr'
return rtl >= ltr ? 'rtl' : 'ltr'
}
function resolveUiDirection(text) {
if (pageDir === 'rtl') return 'rtl'
if (pageLang === 'fa' || pageLang === 'ar' || pageLang === 'he') return 'rtl'
if (/\/persian\b|\/fa\b|bbc\.com\/persian/i.test(pageUrl)) return 'rtl'
return detectTextDirection(text)
}
function rtlAttrs(text) {
if (resolveUiDirection(text) !== 'rtl') return ''
const lang = pageLang && ['fa', 'ar', 'he'].includes(pageLang) ? ` lang="${pageLang}"` : ''
return ` class="text-rtl" dir="rtl"${lang}`
}
function sortNotebooksHierarchy(list) {
const byParent = new Map()
for (const n of list) {
const pid = n.parentId || '__root__'
if (!byParent.has(pid)) byParent.set(pid, [])
byParent.get(pid).push(n)
}
for (const items of byParent.values()) {
items.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'fr'))
}
const out = []
const seen = new Set()
function walk(parentKey, depth) {
for (const n of byParent.get(parentKey) || []) {
if (seen.has(n.id)) continue
seen.add(n.id)
out.push({ ...n, depth })
walk(n.id, depth + 1)
}
}
walk('__root__', 0)
for (const n of list) {
if (!seen.has(n.id)) out.push({ ...n, depth: 0 })
}
return out
}
function notebookSelectHtml() {
const sorted = sortNotebooksHierarchy(notebooks)
const opts = sorted
.map((n) => {
const indent = n.depth > 0 ? '\u00A0\u00A0'.repeat(n.depth) + '↳ ' : ''
const sel = n.id === selectedNotebookId ? ' selected' : ''
return `<option value="${escapeHtml(n.id)}"${sel}>${escapeHtml(indent + (n.name || 'Sans nom'))}</option>`
})
.join('')
return `<select id="notebookSelect" class="notebook-select" aria-label="Carnet de destination">
${notebooks.length ? opts : '<option value="">Aucun carnet</option>'}
</select>`
}
function formatReadingTime(minutes) {
const m = Number(minutes) || 0
if (m <= 0) return ''
if (m === 1) return '1 min de lecture'
return `${m} min de lecture`
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
return tab
}
async function ensureContentScript(tabId) {
try {
const resp = await chrome.tabs.sendMessage(tabId, { type: 'PING' })
if (resp?.ok) return true
} catch {
/* inject */
}
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] })
return true
} catch {
return false
}
}
async function setPickModeOnTab(enabled) {
if (!activeTabId || pageRestricted) return
const ok = await ensureContentScript(activeTabId)
if (!ok) return
try {
await chrome.tabs.sendMessage(activeTabId, { type: 'SET_PICK_MODE', enabled })
} catch {
/* ignore */
}
}
async function syncPickMode() {
await setPickModeOnTab(state === 'idle' && !pageRestricted)
}
function updateConnBadge() {
if (!els.connBadge) return
els.connBadge.hidden = !connected
if (els.connLabel) els.connLabel.textContent = connected ? 'Connecté' : 'Déconnecté'
}
function setSettingsStatus(msg, isError) {
if (!els.settingsStatus) return
els.settingsStatus.hidden = !msg
els.settingsStatus.textContent = msg || ''
els.settingsStatus.className = `settings-status${isError ? ' is-error' : ' is-ok'}`
}
function applyInstanceConfigVisibility() {
if (ALLOW_INSTANCE_CONFIG) return
els.settingsPanel?.setAttribute('hidden', '')
els.settingsBtn?.setAttribute('hidden', '')
if (els.baseUrl) els.baseUrl.value = DEFAULT_BASE
}
function selectionBlockHtml() {
if (selectionText) {
return `<div class="selection-panel has-text" id="selectionSlot">
<div class="selection-head">
<span class="status live"><span class="pulse-dot sky"></span> Sélection détectée</span>
<button type="button" class="clear-btn" id="clearSel">Ignorer</button>
</div>
<div class="selection-body"${rtlAttrs(selectionText)}>「 ${escapeHtml(selectionText)} 」</div>
</div>`
}
return `<div class="selection-hint" id="selectionSlot">
<p>Astuce : surlignez du texte sur la page pour clipper une sélection précise. Le panneau reste ouvert pendant la sélection.</p>
</div>`
}
function actionsBlockHtml() {
const hasSel = Boolean(selectionText)
return `<div class="actions" id="actionsSlot">
${
hasSel
? `<button type="button" class="btn btn-sky" id="clipSelBtn">
${ICON_SELECT} Clipper la sélection
</button>`
: ''
}
<button type="button" class="btn ${hasSel ? 'btn-secondary' : 'btn-primary'}" id="clipPageBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_CLIP} Clipper cette page
</button>
<button type="button" class="btn-link link-only" id="clipLinkBtn" ${pageRestricted ? 'disabled' : ''}>
${ICON_LINK} Enregistrer le lien seul
</button>
</div>`
}
function bindIdleHandlers() {
document.getElementById('notebookSelect')?.addEventListener('change', async (e) => {
selectedNotebookId = e.target.value || ''
await chrome.storage.sync.set({ [STORAGE_KEYS.notebookId]: selectedNotebookId })
})
document.getElementById('clearSel')?.addEventListener('click', () => void clearSelection())
document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection'))
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
}
async function clearSelection() {
selectionText = ''
if (activeTabId) {
try {
await chrome.scripting.executeScript({
target: { tabId: activeTabId },
func: () => window.getSelection()?.removeAllRanges(),
})
} catch {
/* ignore */
}
}
updateSelectionUI()
}
function updateSelectionUI() {
const slot = document.getElementById('selectionSlot')
const actions = document.getElementById('actionsSlot')
if (!slot || !actions || state !== 'idle') {
render()
return
}
slot.outerHTML = selectionBlockHtml()
actions.outerHTML = actionsBlockHtml()
bindIdleHandlers()
}
function applySelectionFromMessage(msg) {
if (!msg || msg.url !== pageUrl) return
selectionText = msg.text || ''
if (msg.dir?.toLowerCase() === 'rtl') pageDir = 'rtl'
if (msg.lang) pageLang = msg.lang
if (state === 'idle') updateSelectionUI()
}
async function refreshPageContext() {
const tab = await getActiveTab()
activeTabId = tab?.id ?? null
pageRestricted = isRestrictedUrl(tab?.url)
if (!tab?.id || pageRestricted) {
pageUrl = tab?.url || ''
pageTitle = tab?.title || 'Page non accessible'
selectionText = ''
return
}
pageUrl = tab.url
pageTitle = tab.title || ''
try {
const u = new URL(pageUrl)
pageDomain = u.hostname
pageFavicon = `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=32`
} catch {
pageDomain = pageUrl
pageFavicon = 'https://www.google.com/s2/favicons?domain=google.com&sz=32'
}
const ok = await ensureContentScript(tab.id)
if (!ok) return
try {
const ctx = await chrome.tabs.sendMessage(tab.id, { type: 'GET_CONTEXT' })
pageHtml = ctx?.html || ''
selectionText = ctx?.text || ''
pageDir = ctx?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
pageLang = ctx?.lang || ''
} catch {
try {
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => ({
html: document.documentElement.outerHTML,
text: window.getSelection()?.toString().trim() || '',
dir: document.documentElement.getAttribute('dir') || '',
lang: (document.documentElement.getAttribute('lang') || '').split('-')[0],
}),
})
pageHtml = result?.html || ''
selectionText = result?.text || ''
pageDir = result?.dir?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr'
pageLang = result?.lang || ''
} catch {
/* ignore */
}
}
}
async function loadSettings() {
const stored = await chrome.storage.sync.get([STORAGE_KEYS.baseUrl, STORAGE_KEYS.notebookId])
if (els.baseUrl) {
els.baseUrl.value = ALLOW_INSTANCE_CONFIG
? stored[STORAGE_KEYS.baseUrl] || DEFAULT_BASE
: DEFAULT_BASE
}
await loadNotebooks(stored[STORAGE_KEYS.notebookId])
}
async function loadNotebooks(preferredId) {
try {
await ensureApiPermission()
const res = await fetch(`${apiBase()}/api/clip/notebooks`, { credentials: 'include' })
if (!res.ok) {
connected = false
updateConnBadge()
if (res.status === 401) {
throw new Error('Connectez-vous à Momento sur la même URL (bouton « Ouvrir Momento »).')
}
throw new Error('Impossible de charger les carnets.')
}
const data = await res.json()
notebooks = data.notebooks || []
selectedNotebookId =
(preferredId && notebooks.some((n) => n.id === preferredId) ? preferredId : '') ||
notebooks[0]?.id ||
''
connected = true
updateConnBadge()
errorMessage = ''
setSettingsStatus('Carnets chargés.', false)
} catch (e) {
notebooks = []
connected = false
updateConnBadge()
errorMessage = e.message
setSettingsStatus(e.message, true)
}
}
async function applyInstance() {
const url = (els.baseUrl?.value || DEFAULT_BASE).replace(/\/$/, '')
if (els.baseUrl) els.baseUrl.value = url
await chrome.storage.sync.set({ [STORAGE_KEYS.baseUrl]: url })
setSettingsStatus('Connexion en cours…', false)
await loadNotebooks(selectedNotebookId)
if (connected) {
setSettingsStatus(`Connecté à ${url}`, false)
}
}
function renderIdle() {
const restrictedBlock = pageRestricted
? `<div class="restricted-note">Cette page ne peut pas être clippée (page système Chrome). Ouvrez un site web normal.</div>`
: ''
const authHint =
!connected && errorMessage
? `<div class="auth-hint">${escapeHtml(errorMessage)}</div>`
: ''
els.screen.innerHTML = `
${restrictedBlock}
${authHint}
<div>
<span class="label">Carnet de destination</span>
${notebookSelectHtml()}
</div>
<div class="page-card">
<span class="sub">Page active</span>
<div class="page-row">
<img src="${escapeHtml(pageFavicon)}" alt="" onerror="this.src='https://www.google.com/s2/favicons?domain=google.com&sz=32'" />
<div class="page-text">
<div class="title"${rtlAttrs(pageTitle)}>${escapeHtml(pageTitle || '—')}</div>
<div class="url">${escapeHtml(pageUrl || '—')}</div>
</div>
</div>
</div>
${selectionBlockHtml()}
${actionsBlockHtml()}
`
bindIdleHandlers()
}
function renderLoading(label) {
els.screen.innerHTML = `
<div class="center-state">
<div class="spinner-wrap">
<div class="spinner-ring"></div>
<div class="spinner"></div>
</div>
<div>
<div class="state-title">Analyse de la source</div>
<div class="state-sub">${escapeHtml(label || 'Traitement en cours…')}</div>
<div class="state-detail">Résumé, tags et préparation de la note Momento.</div>
</div>
</div>
`
}
function renderConfirm() {
const excerpt = analyzeResult?.excerpt || ''
const tags = analyzeResult?.tags || []
const reading = formatReadingTime(analyzeResult?.readingTime)
const tagsHtml = tags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
els.screen.innerHTML = `
<div class="confirm-panel">
<span class="label">Aperçu avant enregistrement</span>
<label class="field">
<span>Titre de la note</span>
<input id="titleInput" type="text" value="${escapeHtml(editableTitle)}" maxlength="300" />
</label>
${
reading
? `<div class="meta-row"><span class="reading-time">${escapeHtml(reading)}</span></div>`
: ''
}
${
analyzeResult?.summary
? `<p class="summary-preview"${rtlAttrs(analyzeResult.summary)}>${escapeHtml(analyzeResult.summary)}</p>`
: ''
}
${
excerpt && pendingClipType !== 'link'
? `<div class="excerpt-preview"${rtlAttrs(excerpt)}>
<span class="excerpt-label">Extrait</span>
${escapeHtml(excerpt)}
</div>`
: ''
}
${tagsHtml ? `<div class="tags preview-tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="saveBtn">Enregistrer dans Momento</button>
<button type="button" class="btn-link" id="cancelConfirmBtn">Retour</button>
</div>
`
document.getElementById('titleInput')?.addEventListener('input', (e) => {
editableTitle = e.target.value
})
document.getElementById('saveBtn')?.addEventListener('click', () => void runSave())
document.getElementById('cancelConfirmBtn')?.addEventListener('click', async () => {
state = 'idle'
analyzeResult = null
await syncPickMode()
render()
})
}
function renderSuccess() {
const nb = notebooks.find((n) => n.id === selectedNotebookId)
const tagsHtml = successTags.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('')
const reading = formatReadingTime(analyzeResult?.readingTime)
els.screen.innerHTML = `
<div class="center-state" style="justify-content:flex-start;padding-top:12px">
<div class="success-icon">✓</div>
<div>
<span class="badge-ok">Note enregistrée</span>
<div class="note-title"${rtlAttrs(successTitle)}>${escapeHtml(successTitle)}</div>
<div class="state-detail">Carnet « ${escapeHtml(nb?.name || '')} »</div>
${reading ? `<div class="state-detail">${escapeHtml(reading)}</div>` : ''}
</div>
${tagsHtml ? `<div class="tags">${tagsHtml}</div>` : ''}
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="viewBtn">Voir dans Momento ↗</button>
<button type="button" class="btn-link" id="againBtn">Clipper autre chose</button>
</div>
`
document.getElementById('viewBtn')?.addEventListener('click', () => {
if (lastNoteUrl) chrome.tabs.create({ url: `${apiBase()}${lastNoteUrl}` })
})
document.getElementById('againBtn')?.addEventListener('click', async () => {
state = 'idle'
analyzeResult = null
await refreshPageContext()
await syncPickMode()
render()
})
}
function renderError() {
els.screen.innerHTML = `
<div class="center-state">
<div class="error-icon">!</div>
<div>
<div class="state-title" style="color:#ef4444">Échec</div>
<div class="state-detail">${escapeHtml(errorMessage || 'Une erreur s\'est produite.')}</div>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-danger" id="retryBtn">Réessayer</button>
<button type="button" class="btn-link" id="backIdleBtn">Retour</button>
</div>
`
document.getElementById('retryBtn')?.addEventListener('click', () => {
if (analyzeResult) void runSave()
else void runAnalyze(pendingClipType)
})
document.getElementById('backIdleBtn')?.addEventListener('click', async () => {
state = 'idle'
errorMessage = ''
analyzeResult = null
await refreshPageContext()
await syncPickMode()
render()
})
}
function render() {
if (state === 'loading' || state === 'saving') return renderLoading(state === 'saving' ? 'Enregistrement…' : 'Analyse…')
if (state === 'confirm') return renderConfirm()
if (state === 'success') return renderSuccess()
if (state === 'error') return renderError()
renderIdle()
}
async function runAnalyze(type) {
pendingClipType = type
state = 'loading'
await setPickModeOnTab(false)
render()
try {
await ensureApiPermission()
await chrome.storage.sync.set({
[STORAGE_KEYS.baseUrl]: apiBase(),
[STORAGE_KEYS.notebookId]: selectedNotebookId,
})
if (type === 'selection') {
if (!selectionText) throw new Error('Aucune sélection active.')
await refreshPageContext()
}
let analyzeBody
if (type === 'link') {
analyzeBody = { url: pageUrl, title: pageTitle, mode: 'link' }
} else if (type === 'selection' && selectionText) {
analyzeBody = { url: pageUrl, title: pageTitle, mode: 'selection', selection: selectionText }
} else {
analyzeBody = { url: pageUrl, html: pageHtml, title: pageTitle, mode: 'article' }
}
const analyzeRes = await fetch(`${apiBase()}/api/clip/analyze`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(analyzeBody),
})
const analysis = await analyzeRes.json()
if (!analyzeRes.ok) throw new Error(analysis.error || 'Analyse impossible')
analyzeResult = analysis
editableTitle = analysis.title || pageTitle || pageDomain
state = 'confirm'
render()
} catch (e) {
errorMessage = e.message || 'Erreur réseau'
state = 'error'
render()
}
}
async function runSave() {
if (!analyzeResult) return
state = 'saving'
render()
try {
const title = (editableTitle || analyzeResult.title || pageTitle || pageDomain).trim()
const saveRes = await fetch(`${apiBase()}/api/clip/save`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: pageUrl,
title,
content: analyzeResult.content,
summary: analyzeResult.summary,
tags: analyzeResult.tags || [],
notebookId: selectedNotebookId || undefined,
}),
})
const saved = await saveRes.json()
if (!saveRes.ok) throw new Error(saved.error || 'Enregistrement impossible')
successTitle = title
successTags = analyzeResult.tags || []
lastNoteId = saved.noteId
lastNoteUrl = saved.noteUrl
state = 'success'
render()
} catch (e) {
errorMessage = e.message || 'Erreur réseau'
state = 'error'
render()
}
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'SELECTION_CHANGED') applySelectionFromMessage(msg)
})
chrome.tabs.onActivated.addListener(async () => {
if (state !== 'idle') return
await refreshPageContext()
await syncPickMode()
render()
})
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
if (info.status !== 'complete' || state !== 'idle') return
const tab = await getActiveTab()
if (tab?.id === tabId) {
await refreshPageContext()
await syncPickMode()
render()
}
})
els.settingsBtn?.addEventListener('click', () => {
if (!ALLOW_INSTANCE_CONFIG) return
els.settingsPanel.hidden = !els.settingsPanel.hidden
})
document.querySelectorAll('.preset-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url')
if (url && els.baseUrl) els.baseUrl.value = url
})
})
els.applyInstanceBtn?.addEventListener('click', () => void applyInstance())
els.openLoginBtn?.addEventListener('click', () => {
chrome.tabs.create({ url: apiBase() })
})
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible' && state === 'idle') {
await refreshPageContext()
await syncPickMode()
render()
}
})
document.addEventListener('DOMContentLoaded', async () => {
applyInstanceConfigVisibility()
await loadSettings()
try {
await ensureApiPermission()
} catch (e) {
errorMessage = e.message
connected = false
updateConnBadge()
}
await refreshPageContext()
await syncPickMode()
render()
})