From bcee65cbad9f8bf9294d2360172d1e6715002837 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 19 May 2026 02:46:51 +0200 Subject: [PATCH] feat: add firefox extension --- extensions/mal-firefox/README.md | 12 ++ extensions/mal-firefox/background.js | 103 ++++++++++++ extensions/mal-firefox/icon.svg | 23 +++ extensions/mal-firefox/manifest.json | 18 +++ extensions/mal-firefox/popup.css | 229 +++++++++++++++++++++++++++ extensions/mal-firefox/popup.html | 51 ++++++ extensions/mal-firefox/popup.js | 74 +++++++++ 7 files changed, 510 insertions(+) create mode 100644 extensions/mal-firefox/README.md create mode 100644 extensions/mal-firefox/background.js create mode 100644 extensions/mal-firefox/icon.svg create mode 100644 extensions/mal-firefox/manifest.json create mode 100644 extensions/mal-firefox/popup.css create mode 100644 extensions/mal-firefox/popup.html create mode 100644 extensions/mal-firefox/popup.js diff --git a/extensions/mal-firefox/README.md b/extensions/mal-firefox/README.md new file mode 100644 index 0000000..bdc3af3 --- /dev/null +++ b/extensions/mal-firefox/README.md @@ -0,0 +1,12 @@ +# MAL Firefox Extension (dev) + +## Load in Firefox + +1. Open `about:debugging#/runtime/this-firefox` +2. Click **Load Temporary Add-on…** +3. Select `extensions/mal-firefox/manifest.json` + +## Usage + +- Click the toolbar icon to open the popup and log in. +- After login, select text on any page → right click → **MyAnimeList** → **Add to Watchlist** → pick a status. diff --git a/extensions/mal-firefox/background.js b/extensions/mal-firefox/background.js new file mode 100644 index 0000000..2e92638 --- /dev/null +++ b/extensions/mal-firefox/background.js @@ -0,0 +1,103 @@ +const MENU_ROOT_ID = 'mal-root'; +const MENU_WATCHLIST_ID = 'mal-watchlist'; +const MENU_STATUS_PREFIX = 'mal-status:'; +const STATUSES = [ + { value: 'watching', label: 'Watching' }, + { value: 'completed', label: 'Completed' }, + { value: 'on_hold', label: 'On Hold' }, + { value: 'dropped', label: 'Dropped' }, + { value: 'plan_to_watch', label: 'Plan to Watch' }, +]; + +async function getSettings() { + const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']); + return { + authToken: authToken || '', + apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech', + }; +} + +async function apiFetch(path, init = {}) { + const { authToken, apiBaseUrl } = await getSettings(); + const url = apiBaseUrl.replace(/\/+$/, '') + path; + const headers = new Headers(init.headers || {}); + if (authToken) headers.set('Authorization', `Bearer ${authToken}`); + const res = await fetch(url, { ...init, headers }); + if (!res.ok) { + const msg = await res.text().catch(() => ''); + throw new Error(msg || `HTTP ${res.status}`); + } + return res; +} + +async function ensureContextMenu() { + const { authToken } = await getSettings(); + await browser.contextMenus.removeAll(); + if (!authToken) return; + + browser.contextMenus.create({ + id: MENU_ROOT_ID, + title: 'MyAnimeList', + contexts: ['selection'], + }); + + browser.contextMenus.create({ + id: MENU_WATCHLIST_ID, + parentId: MENU_ROOT_ID, + title: 'Add to Watchlist', + contexts: ['selection'], + }); + + for (const s of STATUSES) { + browser.contextMenus.create({ + id: MENU_STATUS_PREFIX + s.value, + parentId: MENU_WATCHLIST_ID, + title: s.label, + contexts: ['selection'], + }); + } +} + +browser.runtime.onInstalled.addListener(() => { + ensureContextMenu(); +}); + +browser.runtime.onStartup.addListener(() => { + ensureContextMenu(); +}); + +browser.storage.onChanged.addListener((changes, area) => { + if (area !== 'local') return; + if (changes.authToken) ensureContextMenu(); +}); + +browser.contextMenus.onClicked.addListener(async info => { + if (typeof info.menuItemId !== 'string') return; + if (!info.menuItemId.startsWith(MENU_STATUS_PREFIX)) return; + + const status = info.menuItemId.slice(MENU_STATUS_PREFIX.length); + const text = (info.selectionText || '').trim().replace(/\s+/g, ' ').slice(0, 120); + if (!text) return; + + try { + const searchRes = await apiFetch(`/api/search-quick?q=${encodeURIComponent(text)}`); + const items = await searchRes.json(); + const top = items && items[0]; + if (!top || !top.id) { + await browser.notifications?.create?.({ + type: 'basic', + title: 'MyAnimeList', + message: `No matches for: ${text}`, + }); + return; + } + + await apiFetch('/api/watchlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ animeId: top.id, status }), + }); + } catch { + // Silent failure by default; can be extended with notifications later. + } +}); diff --git a/extensions/mal-firefox/icon.svg b/extensions/mal-firefox/icon.svg new file mode 100644 index 0000000..016dbb5 --- /dev/null +++ b/extensions/mal-firefox/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/mal-firefox/manifest.json b/extensions/mal-firefox/manifest.json new file mode 100644 index 0000000..8ec94a1 --- /dev/null +++ b/extensions/mal-firefox/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 3, + "name": "MyAnimeList", + "version": "0.1.0", + "description": "Right-click selected anime titles and add them to your watchlist.", + "permissions": ["contextMenus", "storage"], + "host_permissions": [""], + "background": { + "scripts": ["background.js"] + }, + "action": { + "default_title": "MAL Watchlist", + "default_popup": "popup.html" + }, + "icons": { + "48": "icon.svg" + } +} diff --git a/extensions/mal-firefox/popup.css b/extensions/mal-firefox/popup.css new file mode 100644 index 0000000..ff6b720 --- /dev/null +++ b/extensions/mal-firefox/popup.css @@ -0,0 +1,229 @@ +:root { + color-scheme: light dark; + --bg: #0b0f1a; + --card: rgba(255, 255, 255, 0.06); + --border: rgba(255, 255, 255, 0.12); + --text: rgba(255, 255, 255, 0.92); + --muted: rgba(255, 255, 255, 0.65); + --accent: #6ea8fe; + --danger: #ff6b6b; + --ok: #4ade80; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f6f7fb; + --card: rgba(0, 0, 0, 0.03); + --border: rgba(0, 0, 0, 0.1); + --text: rgba(0, 0, 0, 0.88); + --muted: rgba(0, 0, 0, 0.6); + --accent: #1f6feb; + --danger: #b42318; + } +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font: + 14px/1.4 system-ui, + -apple-system, + Segoe UI, + Roboto, + sans-serif; +} + +body { + width: 380px; + min-width: 380px; +} + +#app { + padding: 10px; +} + +.panel { + background: transparent; + border-radius: 0; + padding: 12px; + display: grid; + gap: 10px; +} + +.brand { + display: flex; + align-items: center; + gap: 8px; +} + +.brandIcon { + width: 28px; + height: 28px; + border-radius: 8px; +} + +.title { + font-weight: 650; + letter-spacing: 0.2px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.link { + background: transparent; + color: var(--accent); + border: 0; + padding: 6px 0; + cursor: pointer; +} + +.divider { + height: 1px; + background: transparent; + opacity: 0.9; +} + +.subtitle { + font-weight: 600; + color: var(--muted); +} + +.label { + display: grid; + gap: 4px; + color: var(--muted); +} + +.input { + width: 100%; + box-sizing: border-box; + padding: 9px 10px; + border-radius: 0; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.15); + color: var(--text); + outline: none; +} + +.input:focus { + border: 1px solid var(--border); + outline: none; +} + +.btn { + width: 100%; + padding: 10px 12px; + border-radius: 0; + border: 0; + background: rgba(110, 168, 254, 0.18); + color: var(--text); + cursor: pointer; +} + +.btn.danger { + background: rgba(255, 107, 107, 0.18); +} + +.error { + color: var(--danger); +} + +.body { + color: var(--muted); +} + +.login { + display: grid; + gap: 8px; +} + +.statusRow { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--ok); +} + +.statusText { + font-size: 12px; +} + +[hidden] { + display: none !important; +} + +.list { + display: grid; + gap: 8px; +} + +.item { + display: grid; + grid-template-columns: 44px 1fr; + gap: 10px; + align-items: center; + padding: 8px; + border-radius: 10px; + border: 0; +} + +.thumb { + width: 44px; + height: 62px; + border-radius: 8px; + object-fit: cover; + background: rgba(255, 255, 255, 0.08); +} + +.meta { + display: grid; + gap: 4px; +} + +.metaTitle { + font-weight: 650; +} + +.metaSub { + color: var(--muted); + font-size: 12px; +} + +.row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.select { + padding: 8px 10px; + border-radius: 10px; + border: 0; + background: rgba(0, 0, 0, 0.15); + color: var(--text); + flex: 1; +} + +.mini { + padding: 8px 10px; + border-radius: 10px; + border: 0; + background: rgba(110, 168, 254, 0.18); + color: var(--text); + cursor: pointer; +} diff --git a/extensions/mal-firefox/popup.html b/extensions/mal-firefox/popup.html new file mode 100644 index 0000000..d0075e0 --- /dev/null +++ b/extensions/mal-firefox/popup.html @@ -0,0 +1,51 @@ + + + + + + MAL Watchlist + + + +
+
+
+
+ +
MyAnimeList
+
+ +
+ +
+ +
+ Select an anime title on any page, then right click to open the context menu. Under + “MyAnimeList”, choose “Add to Watchlist” and pick a status to save it to your watchlist. +
+ +
+ + + + +
+
+ + + + diff --git a/extensions/mal-firefox/popup.js b/extensions/mal-firefox/popup.js new file mode 100644 index 0000000..eb72d05 --- /dev/null +++ b/extensions/mal-firefox/popup.js @@ -0,0 +1,74 @@ +function qs(id) { + return document.getElementById(id); +} + +async function getSettings() { + const { authToken, apiBaseUrl } = await browser.storage.local.get(['authToken', 'apiBaseUrl']); + return { + authToken: authToken || '', + apiBaseUrl: apiBaseUrl || 'https://mal.mkelvers.tech', + }; +} + +async function setSettings(patch) { + await browser.storage.local.set(patch); +} + +function show(el, on) { + el.hidden = !on; +} + +async function render() { + const settings = await getSettings(); + document.body.dataset.state = settings.authToken ? 'in' : 'out'; + + const logoutBtn = qs('logoutBtn'); + logoutBtn.addEventListener('click', async () => { + await setSettings({ authToken: '' }); + await render(); + }); + + const hasToken = !!settings.authToken; + show(logoutBtn, hasToken); + show(qs('login'), !hasToken); + show(qs('loggedIn'), hasToken); + + if (!hasToken) { + setupLogin(); + return; + } +} + +function setupLogin() { + const loginErr = qs('loginErr'); + show(loginErr, false); + + qs('loginBtn').onclick = async () => { + show(loginErr, false); + const username = qs('username').value.trim(); + const password = qs('password').value; + if (!username || !password) { + loginErr.textContent = 'Missing username or password'; + show(loginErr, true); + return; + } + + try { + const { apiBaseUrl } = await getSettings(); + const res = await fetch(apiBaseUrl.replace(/\/+$/, '') + '/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, name: 'Firefox extension' }), + }); + if (!res.ok) throw new Error('Invalid username or password'); + const data = await res.json(); + await setSettings({ authToken: data.token }); + await render(); + } catch (e) { + loginErr.textContent = e.message || 'Login failed'; + show(loginErr, true); + } + }; +} + +render();