feat: add firefox extension
This commit is contained in:
12
extensions/mal-firefox/README.md
Normal file
12
extensions/mal-firefox/README.md
Normal file
@@ -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.
|
||||||
103
extensions/mal-firefox/background.js
Normal file
103
extensions/mal-firefox/background.js
Normal file
@@ -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.
|
||||||
|
}
|
||||||
|
});
|
||||||
23
extensions/mal-firefox/icon.svg
Normal file
23
extensions/mal-firefox/icon.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bg" cx="35%" cy="35%" r="75%">
|
||||||
|
<stop offset="0%" style="stop-color: var(--accent, #0466c8)" />
|
||||||
|
<stop offset="100%" style="stop-color: var(--accent-dark, #1d4ed8)" />
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clip">
|
||||||
|
<circle cx="50" cy="50" r="45" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Base -->
|
||||||
|
<circle cx="50" cy="50" r="45" fill="url(#bg)" />
|
||||||
|
|
||||||
|
<!-- Crescent moon cutout -->
|
||||||
|
<g clip-path="url(#clip)">
|
||||||
|
<path
|
||||||
|
d="M70 50a25 25 0 1 1 -25 -25 20 20 0 1 0 25 25z"
|
||||||
|
fill="#FFF7ED"
|
||||||
|
transform="translate(-2 -2)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 685 B |
18
extensions/mal-firefox/manifest.json
Normal file
18
extensions/mal-firefox/manifest.json
Normal file
@@ -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": ["<all_urls>"],
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"]
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "MAL Watchlist",
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"48": "icon.svg"
|
||||||
|
}
|
||||||
|
}
|
||||||
229
extensions/mal-firefox/popup.css
Normal file
229
extensions/mal-firefox/popup.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
51
extensions/mal-firefox/popup.html
Normal file
51
extensions/mal-firefox/popup.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>MAL Watchlist</title>
|
||||||
|
<link rel="stylesheet" href="popup.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<section class="panel">
|
||||||
|
<header class="header">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="brandIcon" src="icon.svg" alt="" />
|
||||||
|
<div class="title">MyAnimeList</div>
|
||||||
|
</div>
|
||||||
|
<button id="logoutBtn" class="link" hidden>Log out</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div id="loggedIn" class="statusRow" hidden>
|
||||||
|
<div class="statusDot"></div>
|
||||||
|
<div class="statusText">Signed in — context menu enabled</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="login" class="login" hidden>
|
||||||
|
<label class="label">
|
||||||
|
Username
|
||||||
|
<input id="username" class="input" autocomplete="username" />
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
Password
|
||||||
|
<input id="password" class="input" type="password" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<button id="loginBtn" class="btn">Log in</button>
|
||||||
|
<div id="loginErr" class="error" hidden></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
extensions/mal-firefox/popup.js
Normal file
74
extensions/mal-firefox/popup.js
Normal file
@@ -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();
|
||||||
Reference in New Issue
Block a user