From 97fc7782fb51ec2b6a3b1a3c916f4c3f54c4c652 Mon Sep 17 00:00:00 2001 From: Raph563 <147602212+Raph563@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:59:39 +0100 Subject: [PATCH] =?UTF-8?q?Am=C3=A9liorer=20l'extension:=20ic=C3=B4ne=20ba?= =?UTF-8?q?rre=20d'URL,=20UI=20modernis=C3=A9e=20et=20liens=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 52 +++- firefox-extension/background.js | 31 +++ firefox-extension/icons/icon-16.svg | 6 + firefox-extension/icons/icon-32.svg | 6 + firefox-extension/icons/icon-48.svg | 6 + firefox-extension/manifest.json | 48 ++++ firefox-extension/options/options.css | 53 ++++ firefox-extension/options/options.html | 55 +++++ firefox-extension/options/options.js | 84 +++++++ firefox-extension/popup/popup.css | 78 ++++++ firefox-extension/popup/popup.html | 65 +++++ firefox-extension/popup/popup.js | 216 +++++++++++++++++ mozilla-url-shortener.user.js | 321 +++++++++++++++++++++++++ 13 files changed, 1020 insertions(+), 1 deletion(-) create mode 100644 firefox-extension/background.js create mode 100644 firefox-extension/icons/icon-16.svg create mode 100644 firefox-extension/icons/icon-32.svg create mode 100644 firefox-extension/icons/icon-48.svg create mode 100644 firefox-extension/manifest.json create mode 100644 firefox-extension/options/options.css create mode 100644 firefox-extension/options/options.html create mode 100644 firefox-extension/options/options.js create mode 100644 firefox-extension/popup/popup.css create mode 100644 firefox-extension/popup/popup.html create mode 100644 firefox-extension/popup/popup.js create mode 100644 mozilla-url-shortener.user.js diff --git a/README.md b/README.md index 13d9914..5535342 100644 --- a/README.md +++ b/README.md @@ -1 +1,51 @@ -# URL_shortener_Userscript \ No newline at end of file +# URL_shortener_Userscript + +Le projet fournit maintenant une **WebExtension Firefox** qui affiche un **petit icône dans la barre d’URL** (page action) sur les pages `http(s)`. + +## Fonctionnalités + +- Petit icône directement dans la barre d’URL Firefox. +- Clic sur l’icône → popup de raccourcissement. +- Pré-remplissage automatique avec l’URL de l’onglet actif. +- Services : **Bitly**, **TinyURL**, **Rebrandly**, **is.gd**, **v.gd**. +- Copie rapide du lien + affichage de l’URL finale. +- Page paramètres pour tokens API + historique local. +- Section de liens API et ressources d’icônes pour développeurs. + +## Structure + +- `firefox-extension/manifest.json` +- `firefox-extension/background.js` +- `firefox-extension/popup/*` +- `firefox-extension/options/*` +- `firefox-extension/icons/*` + +## Installation dans Firefox + +1. Ouvre `about:debugging#/runtime/this-firefox`. +2. Clique **Charger un module complémentaire temporaire**. +3. Sélectionne `firefox-extension/manifest.json`. +4. Ouvre n’importe quel site web (`http`/`https`), puis utilise le petit icône dans la barre d’URL. + +## Liens pour récupérer les API + +- Bitly : https://dev.bitly.com/ +- TinyURL : https://tinyurl.com/app/dev +- Rebrandly : https://developers.rebrandly.com/docs +- is.gd API : https://is.gd/apishorteningreference.php +- v.gd API : https://v.gd/apishorteningreference.php + +## Sites d’icônes pour développeurs + +- Google Material Icons : https://fonts.google.com/icons +- Heroicons : https://heroicons.com/ +- Font Awesome : https://fontawesome.com/icons +- Tabler Icons : https://tabler.io/icons + +Tu peux remplacer les icônes par défaut dans `firefox-extension/icons/`, puis ajuster les chemins dans `manifest.json`. + +## Notes + +- Bitly, TinyURL et Rebrandly nécessitent un token. +- `is.gd` et `v.gd` fonctionnent sans token. +- En mode temporaire (`about:debugging`), l’extension doit être rechargée après redémarrage de Firefox. diff --git a/firefox-extension/background.js b/firefox-extension/background.js new file mode 100644 index 0000000..d7cc382 --- /dev/null +++ b/firefox-extension/background.js @@ -0,0 +1,31 @@ +function isSupportedUrl(url) { + return typeof url === 'string' && /^https?:\/\//i.test(url); +} + +async function updatePageAction(tabId) { + try { + const tab = await browser.tabs.get(tabId); + if (tab && isSupportedUrl(tab.url)) { + await browser.pageAction.show(tabId); + } + } catch { + // ignore tabs that cannot be queried/shown + } +} + +browser.tabs.onActivated.addListener(async ({ tabId }) => { + await updatePageAction(tabId); +}); + +browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' || changeInfo.url) { + if (isSupportedUrl(tab.url)) { + await browser.pageAction.show(tabId); + } + } +}); + +browser.runtime.onInstalled.addListener(async () => { + const tabs = await browser.tabs.query({}); + await Promise.all(tabs.map((tab) => updatePageAction(tab.id))); +}); diff --git a/firefox-extension/icons/icon-16.svg b/firefox-extension/icons/icon-16.svg new file mode 100644 index 0000000..bc397f4 --- /dev/null +++ b/firefox-extension/icons/icon-16.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/firefox-extension/icons/icon-32.svg b/firefox-extension/icons/icon-32.svg new file mode 100644 index 0000000..e7650cd --- /dev/null +++ b/firefox-extension/icons/icon-32.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/firefox-extension/icons/icon-48.svg b/firefox-extension/icons/icon-48.svg new file mode 100644 index 0000000..9f5cea1 --- /dev/null +++ b/firefox-extension/icons/icon-48.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/firefox-extension/manifest.json b/firefox-extension/manifest.json new file mode 100644 index 0000000..5b9f798 --- /dev/null +++ b/firefox-extension/manifest.json @@ -0,0 +1,48 @@ +{ + "manifest_version": 2, + "name": "Firefox URL Shortener Hub", + "version": "1.2.0", + "description": "Raccourcir l'URL de l'onglet actif depuis une icône dans la barre d'URL Firefox.", + "permissions": [ + "storage", + "tabs", + "clipboardWrite", + "https://api-ssl.bitly.com/*", + "https://api.tinyurl.com/*", + "https://api.rebrandly.com/*", + "https://is.gd/*", + "https://v.gd/*", + "" + ], + "background": { + "scripts": ["background.js"] + }, + "page_action": { + "default_title": "URL Shortener", + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/icon-16.svg", + "32": "icons/icon-32.svg" + }, + "show_matches": [ + "http://*/*", + "https://*/*" + ] + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": true, + "browser_style": true + }, + "icons": { + "16": "icons/icon-16.svg", + "32": "icons/icon-32.svg", + "48": "icons/icon-48.svg" + }, + "browser_specific_settings": { + "gecko": { + "id": "url-shortener-hub@example.local", + "strict_min_version": "109.0" + } + } +} diff --git a/firefox-extension/options/options.css b/firefox-extension/options/options.css new file mode 100644 index 0000000..2b7a118 --- /dev/null +++ b/firefox-extension/options/options.css @@ -0,0 +1,53 @@ +body { + font-family: Inter, system-ui, Arial, sans-serif; + background: radial-gradient(circle at top, #f0f4ff, #f7f8fc 45%); + margin: 0; + color: #1f2a44; +} +.container { + max-width: 920px; + margin: 24px auto; + padding: 0 12px 24px; + display: grid; + gap: 12px; +} +.panel { + background: #fff; + border: 1px solid #dde3f2; + border-radius: 14px; + padding: 18px; + box-shadow: 0 8px 20px rgba(20, 36, 80, 0.06); +} +h1, h2 { margin: 0 0 8px; } +label { + display: block; + margin: 12px 0 4px; + font-size: 14px; +} +input { + width: 100%; + box-sizing: border-box; + padding: 10px; + border: 1px solid #ccd4e8; + border-radius: 10px; +} +input:focus { + outline: 2px solid #bfd3ff; + border-color: #739cf6; +} +button { + background: linear-gradient(90deg, #0059d6, #0072ff); + color: #fff; + border: none; + border-radius: 10px; + padding: 10px 18px; + cursor: pointer; +} +.actions { margin-top: 14px; } +#historyList { margin: 0; padding-left: 18px; } +#historyList li { margin: 6px 0; word-break: break-all; } +#message { margin-top: 10px; color: #0b6b12; font-weight: 600; } +a { color: #1a4ca8; text-decoration: none; } +a:hover { text-decoration: underline; } +.help { display: inline-block; margin-top: 4px; font-size: 12px; } +code { background: #eef2fb; padding: 2px 6px; border-radius: 6px; } diff --git a/firefox-extension/options/options.html b/firefox-extension/options/options.html new file mode 100644 index 0000000..49a1a53 --- /dev/null +++ b/firefox-extension/options/options.html @@ -0,0 +1,55 @@ + + + + + + Paramètres URL Shortener + + + +
+
+

Paramètres API

+

Configure tes tokens API pour les services premium.

+ + + + Obtenir une clé Bitly + + + + Obtenir une clé TinyURL + + + + Obtenir une clé Rebrandly + + + + +
+ +
+

+
+ +
+

Historique récent

+
    +
    + +
    +

    Icônes développeur recommandées

    + +

    Place tes icônes dans firefox-extension/icons/ et mets à jour manifest.json.

    +
    +
    + + + + diff --git a/firefox-extension/options/options.js b/firefox-extension/options/options.js new file mode 100644 index 0000000..7cbec7c --- /dev/null +++ b/firefox-extension/options/options.js @@ -0,0 +1,84 @@ +const KEYS = { + settings: 'ffExt.settings', + history: 'ffExt.history' +}; + +const DEFAULT_SETTINGS = { + bitlyToken: '', + tinyUrlToken: '', + rebrandlyToken: '', + rebrandlyDomain: '' +}; + +const fields = { + bitlyToken: document.getElementById('bitlyToken'), + tinyUrlToken: document.getElementById('tinyUrlToken'), + rebrandlyToken: document.getElementById('rebrandlyToken'), + rebrandlyDomain: document.getElementById('rebrandlyDomain') +}; + +const saveBtn = document.getElementById('saveBtn'); +const historyList = document.getElementById('historyList'); +const message = document.getElementById('message'); + +init().catch((error) => { + message.textContent = `Erreur: ${error.message}`; +}); + +async function init() { + const settingsData = await browser.storage.sync.get(KEYS.settings); + const settings = { ...DEFAULT_SETTINGS, ...(settingsData[KEYS.settings] || {}) }; + + Object.entries(fields).forEach(([key, node]) => { + node.value = settings[key] || ''; + }); + + saveBtn.addEventListener('click', saveSettings); + await renderHistory(); +} + +async function saveSettings() { + const settings = Object.fromEntries( + Object.entries(fields).map(([key, node]) => [key, node.value.trim()]) + ); + + await browser.storage.sync.set({ [KEYS.settings]: settings }); + message.textContent = 'Paramètres enregistrés.'; +} + +async function renderHistory() { + const historyData = await browser.storage.local.get(KEYS.history); + const history = Array.isArray(historyData[KEYS.history]) ? historyData[KEYS.history] : []; + + historyList.innerHTML = ''; + if (!history.length) { + const li = document.createElement('li'); + li.textContent = 'Aucun lien pour le moment.'; + historyList.appendChild(li); + return; + } + + history.slice(0, 20).forEach((item) => { + const li = document.createElement('li'); + const date = item.createdAt ? new Date(item.createdAt).toLocaleString('fr-FR') : ''; + li.innerHTML = `${escapeHtml(item.service || 'Service')}${escapeHtml(item.shortUrl || '')}${date ? ` (${escapeHtml(date)})` : ''}`; + historyList.appendChild(li); + }); +} + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + + +document.querySelectorAll('a[target="_blank"]').forEach((link) => { + link.addEventListener('click', async (event) => { + event.preventDefault(); + await browser.tabs.create({ url: link.href }); + }); +}); diff --git a/firefox-extension/popup/popup.css b/firefox-extension/popup/popup.css new file mode 100644 index 0000000..80a3fdb --- /dev/null +++ b/firefox-extension/popup/popup.css @@ -0,0 +1,78 @@ +:root { + font-family: Inter, system-ui, Arial, sans-serif; +} +body { + margin: 0; + background: linear-gradient(180deg, #f4f7ff 0%, #eef1fa 100%); +} +.card { + width: 380px; + box-sizing: border-box; + padding: 14px; + border: 1px solid #dbe0ef; + border-radius: 14px; + margin: 6px; + background: #fff; + box-shadow: 0 10px 24px rgba(20, 36, 80, 0.12); +} +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; +} +h1 { margin: 0; font-size: 16px; } +.subtitle { margin: 2px 0 0; font-size: 11px; color: #67708a; } +label { display: block; font-size: 12px; margin: 8px 0 4px; color: #3e4761; } +input, select, button { + width: 100%; + box-sizing: border-box; + border-radius: 10px; + border: 1px solid #c7cfe4; + padding: 9px; + font-size: 13px; +} +input:focus, select:focus { + outline: 2px solid #bfd3ff; + border-color: #769ef6; +} +button { cursor: pointer; background: #eef2fb; } +.primary { + margin-top: 26px; + background: linear-gradient(90deg, #0059d6, #0072ff); + color: #fff; + border: none; +} +.ghost { + width: auto; + padding: 5px 8px; + border-radius: 8px; +} +.split { + display: grid; + grid-template-columns: 1fr 126px; + gap: 8px; +} +.row { display: flex; gap: 6px; margin-top: 8px; } +.small { font-size: 12px; margin: 8px 0 0; color: #4a4f57; min-height: 14px; } +.result { + margin-top: 10px; + border: 1px solid #e5e9f6; + border-radius: 10px; + padding: 9px; + background: #fafcff; +} +.links { + margin-top: 10px; + border-top: 1px dashed #d8dfef; + padding-top: 8px; +} +.links summary { cursor: pointer; color: #2f4d88; font-size: 12px; } +.links ul { margin: 8px 0 0; padding-left: 16px; max-height: 120px; overflow: auto; } +.links li { margin: 4px 0; font-size: 12px; } +.links a { color: #1647a0; text-decoration: none; } +.links a:hover { text-decoration: underline; } +.hidden { display: none; } +#message[data-type="error"] { color: #bb0029; } +#message[data-type="success"] { color: #0b6b12; } +#message[data-type="info"] { color: #425177; } diff --git a/firefox-extension/popup/popup.html b/firefox-extension/popup/popup.html new file mode 100644 index 0000000..a092c67 --- /dev/null +++ b/firefox-extension/popup/popup.html @@ -0,0 +1,65 @@ + + + + + + URL Shortener + + + +
    +
    +
    +

    URL Shortener

    +

    Icône dans la barre d’URL Firefox

    +
    + +
    + + + + +
    +
    + + +
    + +
    + + + + + +

    +
    + + + + diff --git a/firefox-extension/popup/popup.js b/firefox-extension/popup/popup.js new file mode 100644 index 0000000..5644c0c --- /dev/null +++ b/firefox-extension/popup/popup.js @@ -0,0 +1,216 @@ +const KEYS = { + settings: 'ffExt.settings', + history: 'ffExt.history' +}; + +const DEFAULT_SETTINGS = { + bitlyToken: '', + tinyUrlToken: '', + rebrandlyToken: '', + rebrandlyDomain: '' +}; + +const SERVICES = { + bitly: { + label: 'Bitly', + tokenKey: 'bitlyToken', + request: async (url, settings) => { + const response = await fetch('https://api-ssl.bitly.com/v4/shorten', { + method: 'POST', + headers: { + Authorization: `Bearer ${settings.bitlyToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ long_url: url }) + }); + const data = await parseJson(response); + return data.link; + } + }, + tinyurl: { + label: 'TinyURL', + tokenKey: 'tinyUrlToken', + request: async (url, settings) => { + const response = await fetch('https://api.tinyurl.com/create', { + method: 'POST', + headers: { + Authorization: `Bearer ${settings.tinyUrlToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url }) + }); + const data = await parseJson(response); + return data.data?.tiny_url; + } + }, + rebrandly: { + label: 'Rebrandly', + tokenKey: 'rebrandlyToken', + request: async (url, settings) => { + const payload = { destination: url }; + if (settings.rebrandlyDomain) payload.domain = { fullName: settings.rebrandlyDomain }; + + const response = await fetch('https://api.rebrandly.com/v1/links', { + method: 'POST', + headers: { + apikey: settings.rebrandlyToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + const data = await parseJson(response); + return data.shortUrl?.startsWith('http') ? data.shortUrl : `https://${data.shortUrl}`; + } + }, + isgd: { + label: 'is.gd', + request: async (url) => { + const response = await fetch(`https://is.gd/create.php?format=simple&url=${encodeURIComponent(url)}`); + return expectText(response); + } + }, + vgd: { + label: 'v.gd', + request: async (url) => { + const response = await fetch(`https://v.gd/create.php?format=simple&url=${encodeURIComponent(url)}`); + return expectText(response); + } + } +}; + +const ui = { + longUrl: document.getElementById('longUrl'), + shortUrl: document.getElementById('shortUrl'), + service: document.getElementById('service'), + message: document.getElementById('message'), + resultSection: document.getElementById('resultSection'), + expandedText: document.getElementById('expandedText'), + shortenBtn: document.getElementById('shortenBtn'), + copyBtn: document.getElementById('copyBtn'), + expandBtn: document.getElementById('expandBtn'), + openOptions: document.getElementById('openOptions') +}; + +let settingsCache = DEFAULT_SETTINGS; + +init().catch((error) => setMessage(`Erreur init: ${error.message}`, 'error')); + +async function init() { + const settings = await loadSettings(); + settingsCache = settings; + + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + if (tab?.url?.startsWith('http')) { + ui.longUrl.value = tab.url; + } + + ui.shortenBtn.addEventListener('click', onShorten); + ui.copyBtn.addEventListener('click', async () => { + if (!ui.shortUrl.value) return; + await navigator.clipboard.writeText(ui.shortUrl.value); + setMessage('Lien copié dans le presse-papiers.', 'success'); + }); + ui.expandBtn.addEventListener('click', onExpand); + ui.openOptions.addEventListener('click', () => browser.runtime.openOptionsPage()); + + document.querySelectorAll('a[target="_blank"]').forEach((link) => { + link.addEventListener('click', async (event) => { + event.preventDefault(); + await browser.tabs.create({ url: link.href }); + }); + }); + + browser.storage.onChanged.addListener(async (changes, areaName) => { + if (areaName === 'sync' && changes[KEYS.settings]) { + settingsCache = await loadSettings(); + setMessage('Paramètres rechargés.', 'info'); + } + }); +} + +async function onShorten() { + const longUrl = ui.longUrl.value.trim(); + const serviceKey = ui.service.value; + const service = SERVICES[serviceKey]; + + if (!isValidUrl(longUrl)) return setMessage('URL invalide.', 'error'); + if (!service) return setMessage('Service inconnu.', 'error'); + if (service.tokenKey && !settingsCache[service.tokenKey]) { + return setMessage(`Ajoute la clé API ${service.label} dans les paramètres.`, 'error'); + } + + setMessage('Raccourcissement en cours...', 'info'); + try { + const shortUrl = await service.request(longUrl, settingsCache); + if (!isValidUrl(shortUrl)) throw new Error('Réponse de service invalide.'); + + ui.shortUrl.value = shortUrl; + ui.expandedText.textContent = ''; + ui.resultSection.classList.remove('hidden'); + setMessage('Lien généré.', 'success'); + + await saveHistory({ + id: crypto.randomUUID(), + service: service.label, + longUrl, + shortUrl, + createdAt: Date.now() + }); + } catch (error) { + setMessage(`Erreur: ${error.message}`, 'error'); + } +} + +async function onExpand() { + const value = ui.shortUrl.value.trim(); + if (!isValidUrl(value)) return setMessage('Aucune URL raccourcie valide.', 'error'); + + setMessage('Récupération de l’URL finale...', 'info'); + try { + const response = await fetch(value, { redirect: 'follow', cache: 'no-store' }); + ui.expandedText.textContent = `URL finale: ${response.url}`; + setMessage('URL finale récupérée.', 'success'); + } catch (error) { + setMessage(`Impossible d'obtenir l'URL finale: ${error.message}`, 'error'); + } +} + +async function loadSettings() { + const data = await browser.storage.sync.get(KEYS.settings); + return { ...DEFAULT_SETTINGS, ...(data[KEYS.settings] || {}) }; +} + +async function saveHistory(entry) { + const data = await browser.storage.local.get(KEYS.history); + const history = Array.isArray(data[KEYS.history]) ? data[KEYS.history] : []; + history.unshift(entry); + await browser.storage.local.set({ [KEYS.history]: history.slice(0, 30) }); +} + +async function parseJson(response) { + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `HTTP ${response.status}`); + } + return response.json(); +} + +async function expectText(response) { + const text = await response.text(); + if (!response.ok) throw new Error(text || `HTTP ${response.status}`); + return text.trim(); +} + +function setMessage(text, type) { + ui.message.textContent = text; + ui.message.dataset.type = type; +} + +function isValidUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} diff --git a/mozilla-url-shortener.user.js b/mozilla-url-shortener.user.js new file mode 100644 index 0000000..06829da --- /dev/null +++ b/mozilla-url-shortener.user.js @@ -0,0 +1,321 @@ +// ==UserScript== +// @name Firefox URL Shortener Hub +// @namespace https://addons.mozilla.org/ +// @version 1.1.0 +// @description Widget type extension pour raccourcir/expanser des URL depuis n'importe quelle page web dans Firefox. +// @author Codex +// @match http://*/* +// @match https://*/* +// @exclude about:* +// @exclude moz-extension://* +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_xmlhttpRequest +// @connect api-ssl.bitly.com +// @connect api.tinyurl.com +// @connect api.rebrandly.com +// @connect is.gd +// @connect v.gd +// ==/UserScript== + +(function () { + 'use strict'; + + const STORAGE_KEYS = { + settings: 'ffShortener.settings', + links: 'ffShortener.links', + panelOpen: 'ffShortener.panelOpen' + }; + + const DEFAULT_SETTINGS = { + bitlyToken: '', + tinyUrlToken: '', + rebrandlyToken: '', + rebrandlyDomain: '' + }; + + const SERVICES = { + bitly: { + label: 'Bitly', + needsToken: 'bitlyToken', + shorten: (url, settings) => apiRequest({ method: 'POST', url: 'https://api-ssl.bitly.com/v4/shorten', headers: { Authorization: `Bearer ${settings.bitlyToken}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ long_url: url }) }).then((res) => parseJson(res).link) + }, + tinyurl: { + label: 'TinyURL', + needsToken: 'tinyUrlToken', + shorten: (url, settings) => apiRequest({ method: 'POST', url: 'https://api.tinyurl.com/create', headers: { Authorization: `Bearer ${settings.tinyUrlToken}`, 'Content-Type': 'application/json' }, data: JSON.stringify({ url }) }).then((res) => parseJson(res).data.tiny_url) + }, + rebrandly: { + label: 'Rebrandly', + needsToken: 'rebrandlyToken', + shorten: (url, settings) => apiRequest({ method: 'POST', url: 'https://api.rebrandly.com/v1/links', headers: { apikey: settings.rebrandlyToken, 'Content-Type': 'application/json' }, data: JSON.stringify({ destination: url, domain: settings.rebrandlyDomain ? { fullName: settings.rebrandlyDomain } : undefined }) }).then((res) => { + const data = parseJson(res); + return data.shortUrl.startsWith('http') ? data.shortUrl : `https://${data.shortUrl}`; + }) + }, + isgd: { + label: 'is.gd', + shorten: (url) => apiRequest({ method: 'GET', url: `https://is.gd/create.php?format=simple&url=${encodeURIComponent(url)}` }).then((res) => res.responseText.trim()) + }, + vgd: { + label: 'v.gd', + shorten: (url) => apiRequest({ method: 'GET', url: `https://v.gd/create.php?format=simple&url=${encodeURIComponent(url)}` }).then((res) => res.responseText.trim()) + } + }; + + const state = { + settings: loadData(STORAGE_KEYS.settings, DEFAULT_SETTINGS), + links: loadData(STORAGE_KEYS.links, []), + panelOpen: Boolean(loadData(STORAGE_KEYS.panelOpen, false)) + }; + + injectStyles(); + const ui = createUI(); + document.body.appendChild(ui.launcher); + document.body.appendChild(ui.panel); + setPanelOpen(state.panelOpen, ui.panel, ui.launcher); + renderLinks(ui.linksContainer, ui.emptyState); + + function createUI() { + const launcher = document.createElement('button'); + launcher.type = 'button'; + launcher.className = 'ff-shortener-launcher'; + launcher.title = 'Ouvrir URL Shortener'; + launcher.textContent = '🔗'; + + const panel = document.createElement('aside'); + panel.className = 'ff-shortener-widget'; + + panel.innerHTML = ` +
    + Firefox URL Shortener +
    + + + +
    +
    + + + + + +

    + + Note: un UserScript ne peut pas ajouter un vrai bouton de barre d'outils Firefox (il faut une WebExtension). + `; + + const urlInput = panel.querySelector('#ff-shortener-url'); + const serviceSelect = panel.querySelector('#ff-shortener-service'); + const message = panel.querySelector('[data-role="message"]'); + const linksContainer = panel.querySelector('[data-role="links"]'); + const emptyState = panel.querySelector('[data-role="empty"]'); + const listWrap = panel.querySelector('[data-role="list-wrap"]'); + + launcher.addEventListener('click', () => { + setPanelOpen(!state.panelOpen, panel, launcher); + }); + + panel.addEventListener('click', async (event) => { + const target = event.target.closest('[data-action]'); + const action = target?.dataset.action; + if (!action) return; + + if (action === 'shorten') { + await shortenUrl(urlInput.value.trim(), serviceSelect.value, message); + renderLinks(linksContainer, emptyState); + } + if (action === 'toggle-list') listWrap.classList.toggle('hidden'); + if (action === 'open-settings') openSettingsModal(); + if (action === 'hide-panel') setPanelOpen(false, panel, launcher); + if (action === 'expand') await expandLink(target.dataset.id, message, linksContainer, emptyState); + if (action === 'copy') { + await navigator.clipboard.writeText(target.dataset.url || ''); + setMessage(message, 'Lien copié.', 'success'); + } + }); + + return { launcher, panel, linksContainer, emptyState }; + } + + function setPanelOpen(open, panel, launcher) { + state.panelOpen = open; + saveData(STORAGE_KEYS.panelOpen, state.panelOpen); + panel.classList.toggle('hidden', !open); + launcher.classList.toggle('hidden', open); + } + + function openSettingsModal() { + const overlay = document.createElement('div'); + overlay.className = 'ff-shortener-overlay'; + overlay.innerHTML = ` + + `; + + overlay.addEventListener('click', (event) => { + const action = event.target.dataset.action; + if (event.target === overlay || action === 'cancel') overlay.remove(); + if (action === 'save') { + overlay.querySelectorAll('[data-key]').forEach((input) => { + state.settings[input.dataset.key] = input.value.trim(); + }); + saveData(STORAGE_KEYS.settings, state.settings); + overlay.remove(); + } + }); + + document.body.appendChild(overlay); + } + + function renderLinks(container, emptyState) { + container.innerHTML = ''; + emptyState.style.display = state.links.length ? 'none' : 'block'; + state.links.forEach((item) => { + const li = document.createElement('li'); + li.className = 'ff-shortener-link-item'; + li.innerHTML = ` +
    ${item.service}
    + ${item.shortUrl} + ${item.expandedUrl ? `
    URL complète: ${item.expandedUrl}
    ` : ''} +
    + + +
    + `; + container.appendChild(li); + }); + } + + async function shortenUrl(longUrl, serviceKey, messageNode) { + if (!isValidUrl(longUrl)) return setMessage(messageNode, 'URL invalide.', 'error'); + const service = SERVICES[serviceKey]; + if (!service) return setMessage(messageNode, 'Service inconnu.', 'error'); + if (service.needsToken && !state.settings[service.needsToken]) { + return setMessage(messageNode, `Ajoute la clé API pour ${service.label} dans les paramètres.`, 'error'); + } + + setMessage(messageNode, 'Raccourcissement en cours...', 'info'); + try { + const shortUrl = await service.shorten(longUrl, state.settings); + if (!isValidUrl(shortUrl)) throw new Error('Réponse invalide du service.'); + state.links.unshift({ id: crypto.randomUUID(), service: service.label, longUrl, shortUrl, expandedUrl: '' }); + state.links = state.links.slice(0, 20); + saveData(STORAGE_KEYS.links, state.links); + setMessage(messageNode, `URL raccourcie: ${shortUrl}`, 'success'); + } catch (error) { + setMessage(messageNode, `Erreur: ${error.message}`, 'error'); + } + } + + async function expandLink(id, messageNode, linksContainer, emptyState) { + const item = state.links.find((link) => link.id === id); + if (!item) return setMessage(messageNode, 'Lien introuvable.', 'error'); + + setMessage(messageNode, 'Récupération de l\'URL complète...', 'info'); + try { + const response = await apiRequest({ method: 'GET', url: item.shortUrl, headers: { 'Cache-Control': 'no-cache' } }); + item.expandedUrl = response.finalUrl || item.shortUrl; + saveData(STORAGE_KEYS.links, state.links); + renderLinks(linksContainer, emptyState); + setMessage(messageNode, 'URL complète récupérée.', 'success'); + } catch (error) { + setMessage(messageNode, `Impossible d\'afficher l'URL complète: ${error.message}`, 'error'); + } + } + + function apiRequest(options) { + if (typeof GM_xmlhttpRequest === 'function') { + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + ...options, + onload: (response) => (response.status >= 200 && response.status < 400 ? resolve(response) : reject(new Error(response.responseText || `HTTP ${response.status}`))), + onerror: () => reject(new Error('Requête réseau bloquée.')) + }); + }); + } + + return fetch(options.url, { method: options.method, headers: options.headers, body: options.data, redirect: 'follow' }).then(async (response) => { + const text = await response.text(); + if (!response.ok) throw new Error(text || `HTTP ${response.status}`); + return { responseText: text, finalUrl: response.url, status: response.status }; + }); + } + + function parseJson(response) { + try { return JSON.parse(response.responseText); } catch { throw new Error('Réponse JSON invalide.'); } + } + function loadData(key, fallback) { + try { + if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : fallback; + } catch { return fallback; } + } + function saveData(key, value) { + if (typeof GM_setValue === 'function') return GM_setValue(key, value); + localStorage.setItem(key, JSON.stringify(value)); + } + function setMessage(node, text, type) { node.textContent = text; node.dataset.type = type; } + function isValidUrl(value) { + try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol); } catch { return false; } + } + function escapeHtml(value) { + return String(value || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + function injectStyles() { + const css = ` + .ff-shortener-launcher { + position: fixed; top: 18px; right: 18px; z-index: 2147483647; + width: 42px; height: 42px; border: none; border-radius: 999px; + background: #ff7139; color: #fff; cursor: pointer; font-size: 18px; + box-shadow: 0 8px 20px rgba(0,0,0,.2); + } + .ff-shortener-widget { + position: fixed; top: 16px; right: 16px; width: 330px; z-index: 2147483647; + background: #fff; border: 1px solid #d8d8e2; border-radius: 12px; padding: 12px; + box-shadow: 0 8px 20px rgba(0,0,0,.12); font-family: system-ui,sans-serif; color: #111; + } + .ff-shortener-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } + .ff-shortener-label { display: block; font-size: 12px; margin: 8px 0 4px; } + .ff-shortener-input,.ff-shortener-select { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #c8c8d0; box-sizing: border-box; } + .ff-shortener-primary,.ff-shortener-btn { border: none; border-radius: 8px; padding: 7px 10px; cursor: pointer; margin-top: 8px; } + .ff-shortener-primary { background: #0060df; color: #fff; width: 100%; } + .ff-shortener-btn { background: #ebebf0; color: #111; } + .ff-shortener-message { font-size: 12px; margin: 8px 0; } + .ff-shortener-message[data-type="error"] { color: #c7001e; } + .ff-shortener-message[data-type="success"] { color: #006504; } + .ff-shortener-message[data-type="info"] { color: #4a4f57; } + .ff-shortener-list ul { margin: 0; padding: 0; list-style: none; max-height: 240px; overflow: auto; } + .ff-shortener-link-item { padding: 8px; border: 1px solid #ececf2; border-radius: 8px; margin-bottom: 8px; font-size: 12px; } + .ff-shortener-link-item a { word-break: break-all; } + .ff-shortener-actions { display: flex; gap: 6px; } + .ff-shortener-expanded { margin-top: 6px; } + .ff-shortener-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 2147483647; display: flex; align-items: center; justify-content: center; } + .ff-shortener-modal { width: min(420px, calc(100vw - 24px)); background: #fff; border-radius: 12px; padding: 14px; } + .ff-shortener-modal label { display: block; font-size: 13px; margin-bottom: 10px; } + .ff-shortener-modal-actions { display: flex; justify-content: flex-end; gap: 6px; } + .ff-shortener-note { color: #5b5f66; display: block; margin-top: 10px; font-size: 11px; } + .hidden { display: none !important; } + `; + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); + } +})();