Neu: Transmission Remote Script

This commit is contained in:
Akamaru
2025-07-11 20:47:08 +02:00
parent db23badced
commit ce943f98ce

View File

@ -0,0 +1,780 @@
// ==UserScript==
// @name Transmission Remote Script
// @namespace https://git.ponywave.de/Akamaru/Userscripts
// @version 1.0
// @description Sendet Magnet-Links und .torrent-Dateien an Transmission im Netzwerk mit Pfad-Auswahl und Verwaltung
// @author Akamaru
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @updateURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/torrent-to-transmission.user.js
// ==/UserScript==
(function() {
'use strict';
// --- Hilfsfunktionen für Storage ---
async function getConfig() {
let config = await GM_getValue('transmissionConfig');
if (config) return JSON.parse(config);
return await askForConfig();
}
async function setConfig(config) {
await GM_setValue('transmissionConfig', JSON.stringify(config));
}
async function askForConfig() {
let ipport = prompt('Transmission IP und Port (z.B. 192.168.1.100:9091):');
if (!ipport) return null;
let user = prompt('Benutzername (leer lassen, falls nicht benötigt):');
let pass = prompt('Passwort (leer lassen, falls nicht benötigt):');
let url = 'http://' + ipport + '/transmission/rpc';
let config = { url, user, pass };
await setConfig(config);
return config;
}
async function getPaths() {
let paths = await GM_getValue('transmissionPaths');
if (paths) return JSON.parse(paths);
return [];
}
async function setPaths(paths) {
await GM_setValue('transmissionPaths', JSON.stringify(paths));
}
// --- Hilfsfunktionen für modale Dialoge ---
function showModalDialog({title, content, onOk, onCancel, okText = 'OK', cancelText = 'Abbrechen', showCancel = true}) {
let overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.background = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = 99999;
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
let dialog = document.createElement('div');
dialog.style.background = '#222';
dialog.style.color = '#fff';
dialog.style.padding = '24px 32px';
dialog.style.borderRadius = '10px';
dialog.style.minWidth = '480px';
dialog.style.boxShadow = '0 4px 24px rgba(0,0,0,0.4)';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
dialog.style.gap = '16px';
if (title) {
let h2 = document.createElement('h2');
h2.textContent = title;
h2.style.margin = '0 0 8px 0';
dialog.appendChild(h2);
}
if (typeof content === 'string') {
let p = document.createElement('div');
p.innerHTML = content;
dialog.appendChild(p);
} else if (content instanceof Node) {
dialog.appendChild(content);
}
let btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.justifyContent = 'flex-end';
btnRow.style.gap = '12px';
if (showCancel) {
let cancelBtn = document.createElement('button');
cancelBtn.textContent = cancelText;
cancelBtn.style.background = '#666';
cancelBtn.style.color = '#fff';
cancelBtn.style.border = 'none';
cancelBtn.style.padding = '8px 18px';
cancelBtn.style.borderRadius = '4px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.onclick = function() {
document.body.removeChild(overlay);
if (onCancel) onCancel();
};
btnRow.appendChild(cancelBtn);
}
let okBtn = document.createElement('button');
okBtn.textContent = okText;
okBtn.style.background = '#6c3';
okBtn.style.color = '#222';
okBtn.style.border = 'none';
okBtn.style.padding = '8px 18px';
okBtn.style.borderRadius = '4px';
okBtn.style.cursor = 'pointer';
okBtn.onclick = function() {
document.body.removeChild(overlay);
if (onOk) onOk();
};
btnRow.appendChild(okBtn);
dialog.appendChild(btnRow);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
return {overlay, dialog};
}
// --- Eigener Dialog für Pfadverwaltung ---
async function editPathsMenu() {
let paths = await getPaths();
let container = document.createElement('div');
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '10px';
let list = document.createElement('ul');
list.style.listStyle = 'none';
list.style.padding = '0';
list.style.margin = '0';
function renderList() {
list.innerHTML = '';
paths.forEach((p, i) => {
let li = document.createElement('li');
li.style.display = 'flex';
li.style.alignItems = 'center';
li.style.gap = '8px';
let span = document.createElement('span');
span.textContent = p;
li.appendChild(span);
let delBtn = document.createElement('button');
delBtn.textContent = 'Löschen';
delBtn.style.background = '#a33';
delBtn.style.color = '#fff';
delBtn.style.border = 'none';
delBtn.style.padding = '2px 8px';
delBtn.style.borderRadius = '4px';
delBtn.style.cursor = 'pointer';
delBtn.onclick = async function() {
paths.splice(i, 1);
await setPaths(paths);
renderList();
};
li.appendChild(delBtn);
list.appendChild(li);
});
}
renderList();
container.appendChild(list);
let addRow = document.createElement('div');
addRow.style.display = 'flex';
addRow.style.gap = '8px';
let input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Neuen Pfad eingeben...';
input.style.flex = '1';
input.style.padding = '4px';
input.style.borderRadius = '4px';
input.style.border = '1px solid #888';
input.style.background = '#333';
input.style.color = '#fff';
addRow.appendChild(input);
let addBtn = document.createElement('button');
addBtn.textContent = 'Hinzufügen';
addBtn.style.background = '#444';
addBtn.style.color = '#fff';
addBtn.style.border = 'none';
addBtn.style.padding = '4px 12px';
addBtn.style.borderRadius = '4px';
addBtn.style.cursor = 'pointer';
addBtn.onclick = async function() {
let val = input.value.trim();
if (val) {
paths.push(val);
await setPaths(paths);
input.value = '';
renderList();
}
};
addRow.appendChild(addBtn);
container.appendChild(addRow);
showModalDialog({
title: 'Download-Pfade verwalten',
content: container,
okText: 'Schließen',
showCancel: false
});
}
// --- Eigener Dialog für Transmission-Konfiguration ---
async function askForConfig() {
return new Promise(resolve => {
let container = document.createElement('div');
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '10px';
let ipInput = document.createElement('input');
ipInput.type = 'text';
ipInput.placeholder = 'IP und Port (z.B. 192.168.1.100:9091)';
ipInput.style.padding = '4px';
ipInput.style.borderRadius = '4px';
ipInput.style.border = '1px solid #888';
ipInput.style.background = '#333';
ipInput.style.color = '#fff';
container.appendChild(ipInput);
let userInput = document.createElement('input');
userInput.type = 'text';
userInput.placeholder = 'Benutzername (optional)';
userInput.style.padding = '4px';
userInput.style.borderRadius = '4px';
userInput.style.border = '1px solid #888';
userInput.style.background = '#333';
userInput.style.color = '#fff';
container.appendChild(userInput);
let passInput = document.createElement('input');
passInput.type = 'password';
passInput.placeholder = 'Passwort (optional)';
passInput.style.padding = '4px';
passInput.style.borderRadius = '4px';
passInput.style.border = '1px solid #888';
passInput.style.background = '#333';
passInput.style.color = '#fff';
container.appendChild(passInput);
showModalDialog({
title: 'Transmission konfigurieren',
content: container,
okText: 'Speichern',
cancelText: 'Abbrechen',
showCancel: true,
onOk: async () => {
let ipport = ipInput.value.trim();
let user = userInput.value.trim();
let pass = passInput.value;
if (!ipport) return;
let url = 'http://' + ipport + '/transmission/rpc';
let config = { url, user, pass };
await setConfig(config);
resolve(config);
},
onCancel: () => resolve(null)
});
});
}
// --- Transmission Session-ID holen ---
function getSessionId(config, callback) {
let headers = {};
if (config.user) {
headers['Authorization'] = 'Basic ' + btoa(config.user + ':' + config.pass);
}
GM_xmlhttpRequest({
method: 'POST',
url: config.url,
headers: headers,
onload: function(response) {
const match = response.responseHeaders.match(/x-transmission-session-id:(.+)/i);
const sessionId = match ? match[1].trim() : null;
callback(sessionId);
},
onerror: function() {
showToast('Fehler beim Abrufen der Session-ID!');
}
});
}
// --- Magnet/Torrent an Transmission senden ---
function sendToTransmission(data, isMagnet, downloadDir, retry) {
getConfig().then(config => {
getSessionId(config, function(sessionId) {
let headers = {
'Content-Type': 'application/json',
'X-Transmission-Session-Id': sessionId
};
if (config.user) {
headers['Authorization'] = 'Basic ' + btoa(config.user + ':' + config.pass);
}
let args = isMagnet ? { filename: data } : { metainfo: data };
if (downloadDir) args['download-dir'] = downloadDir;
let body = {
method: 'torrent-add',
arguments: args
};
GM_xmlhttpRequest({
method: 'POST',
url: config.url,
headers: headers,
data: JSON.stringify(body),
onload: function(response) {
if (response.status === 409 && !retry) {
const match = response.responseHeaders.match(/x-transmission-session-id:(.+)/i);
const newSessionId = match ? match[1].trim() : null;
if (newSessionId) {
headers['X-Transmission-Session-Id'] = newSessionId;
GM_xmlhttpRequest({
method: 'POST',
url: config.url,
headers: headers,
data: JSON.stringify(body),
onload: function(resp2) {
if (resp2.status === 409) {
showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + resp2.responseHeaders);
} else {
showToast('An Transmission gesendet!');
}
},
onerror: function() {
showToast('Fehler beim Senden an Transmission!');
}
});
} else {
showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + response.responseHeaders);
}
} else if (response.status === 409 && retry) {
showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + response.responseHeaders);
} else {
showToast('An Transmission gesendet!');
}
},
onerror: function() {
showToast('Fehler beim Senden an Transmission!');
}
});
});
});
}
// --- .torrent-Datei herunterladen und in Base64 umwandeln ---
function fetchAndEncodeTorrent(url, callback) {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
onload: function(response) {
const bytes = new Uint8Array(response.response);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
callback(base64);
},
onerror: function() {
showToast('Fehler beim Herunterladen der .torrent-Datei!');
}
});
}
// --- Dialog für Pfad-Auswahl und Senden ---
async function showPathDialog(onSend, onCancel, fileList) {
let paths = await getPaths();
// Dialog-Elemente erzeugen
let overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.background = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = 99999;
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
let dialog = document.createElement('div');
dialog.style.background = '#222';
dialog.style.color = '#fff';
dialog.style.padding = '24px 32px';
dialog.style.borderRadius = '10px';
dialog.style.minWidth = '480px';
dialog.style.boxShadow = '0 4px 24px rgba(0,0,0,0.4)';
dialog.style.display = 'flex';
dialog.style.flexDirection = 'column';
dialog.style.gap = '16px';
let title = document.createElement('h2');
title.textContent = 'Download-Verzeichnis wählen';
title.style.margin = '0 0 8px 0';
dialog.appendChild(title);
// Dateiliste für .torrent-Dateien mit Checkboxen
let selectedFiles = [];
let fileCheckboxes = [];
if (fileList && fileList.length > 0) {
selectedFiles = fileList.map(() => true); // standardmäßig alle ausgewählt
let fileBox = document.createElement('div');
fileBox.style.maxHeight = '180px';
fileBox.style.overflowY = 'auto';
fileBox.style.background = '#181818';
fileBox.style.border = '1px solid #444';
fileBox.style.borderRadius = '6px';
fileBox.style.padding = '8px 12px';
fileBox.style.marginBottom = '8px';
fileBox.style.width = '100%';
let fileTitle = document.createElement('div');
fileTitle.textContent = 'Dateien im Torrent:';
fileTitle.style.fontWeight = 'bold';
fileTitle.style.marginBottom = '6px';
fileBox.appendChild(fileTitle);
// Buttons für alle an/abwählen
let btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.gap = '8px';
btnRow.style.marginBottom = '8px';
let allOnBtn = document.createElement('button');
allOnBtn.textContent = 'Alle auswählen';
allOnBtn.style.background = '#444';
allOnBtn.style.color = '#fff';
allOnBtn.style.border = 'none';
allOnBtn.style.padding = '2px 10px';
allOnBtn.style.borderRadius = '4px';
allOnBtn.style.cursor = 'pointer';
allOnBtn.onclick = function() {
fileCheckboxes.forEach((cb, i) => { cb.checked = true; selectedFiles[i] = true; });
};
btnRow.appendChild(allOnBtn);
let allOffBtn = document.createElement('button');
allOffBtn.textContent = 'Alle abwählen';
allOffBtn.style.background = '#444';
allOffBtn.style.color = '#fff';
allOffBtn.style.border = 'none';
allOffBtn.style.padding = '2px 10px';
allOffBtn.style.borderRadius = '4px';
allOffBtn.style.cursor = 'pointer';
allOffBtn.onclick = function() {
fileCheckboxes.forEach((cb, i) => { cb.checked = false; selectedFiles[i] = false; });
};
btnRow.appendChild(allOffBtn);
fileBox.appendChild(btnRow);
fileList.forEach((f, idx) => {
let fileRow = document.createElement('div');
fileRow.style.display = 'flex';
fileRow.style.alignItems = 'center';
fileRow.style.gap = '8px';
fileRow.style.fontSize = '0.98em';
fileRow.style.wordBreak = 'break-all';
fileRow.style.whiteSpace = 'normal';
let cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = true;
cb.onchange = function() { selectedFiles[idx] = cb.checked; };
fileCheckboxes.push(cb);
fileRow.appendChild(cb);
let label = document.createElement('span');
label.textContent = f;
fileRow.appendChild(label);
fileBox.appendChild(fileRow);
});
dialog.appendChild(fileBox);
}
let select = document.createElement('select');
select.style.fontSize = '1.1em';
select.style.padding = '6px';
select.style.marginBottom = '8px';
select.style.borderRadius = '4px';
select.style.border = '1px solid #888';
select.style.background = '#333';
select.style.color = '#fff';
select.style.width = '100%';
paths.forEach(p => {
let opt = document.createElement('option');
opt.value = p;
opt.textContent = p;
select.appendChild(opt);
});
dialog.appendChild(select);
let btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.justifyContent = 'flex-end';
btnRow.style.gap = '12px';
let cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Abbrechen';
cancelBtn.style.background = '#666';
cancelBtn.style.color = '#fff';
cancelBtn.style.border = 'none';
cancelBtn.style.padding = '8px 18px';
cancelBtn.style.borderRadius = '4px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.onclick = function() {
document.body.removeChild(overlay);
if (onCancel) onCancel();
};
btnRow.appendChild(cancelBtn);
let sendBtn = document.createElement('button');
sendBtn.textContent = 'Senden';
sendBtn.style.background = '#6c3';
sendBtn.style.color = '#222';
sendBtn.style.border = 'none';
sendBtn.style.padding = '8px 18px';
sendBtn.style.borderRadius = '4px';
sendBtn.style.cursor = 'pointer';
sendBtn.onclick = function() {
let dir = select.value;
document.body.removeChild(overlay);
if (onSend) {
if (fileList && fileList.length > 0) {
// Übergebe die Auswahl als Array von bools
onSend(dir, selectedFiles);
} else {
onSend(dir);
}
}
};
btnRow.appendChild(sendBtn);
dialog.appendChild(btnRow);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
// --- Hilfsfunktion: Nach dem Hinzufügen eines Torrents Dateien abwählen ---
function setUnwantedFiles(torrentId, unwantedIndices, config) {
if (!unwantedIndices || unwantedIndices.length === 0) return;
let headers = {
'Content-Type': 'application/json',
};
if (config.user) {
headers['Authorization'] = 'Basic ' + btoa(config.user + ':' + config.pass);
}
getSessionId(config, function(sessionId) {
headers['X-Transmission-Session-Id'] = sessionId;
let body = {
method: 'torrent-set',
arguments: {
ids: [torrentId],
'files-unwanted': unwantedIndices
}
};
GM_xmlhttpRequest({
method: 'POST',
url: config.url,
headers: headers,
data: JSON.stringify(body),
onload: function(response) {
// Optional: Rückmeldung anzeigen
if (response.status === 200) {
showToast('Dateiauswahl an Transmission übermittelt!');
} else {
showToast('Fehler beim Setzen der Dateiauswahl!');
}
},
onerror: function() {
showToast('Fehler beim Setzen der Dateiauswahl!');
}
});
});
}
// --- Hilfsfunktion: Torrent-Dateiliste aus bencode extrahieren ---
function parseTorrentFileList(arrayBuffer) {
// Minimaler Bencode-Parser für 'info.files' oder 'info.name' (single file)
// Korrigiert: Dateinamen werden anhand der Längenangabe korrekt extrahiert
try {
let bytes = new Uint8Array(arrayBuffer);
let str = '';
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
// Suche nach '4:info' und dann nach '5:files' oder '4:name'
let infoIdx = str.indexOf('4:info');
if (infoIdx === -1) return [];
let filesIdx = str.indexOf('5:files', infoIdx);
if (filesIdx !== -1) {
// Multi-File Torrent: Dateinamen extrahieren
let fileList = [];
// Suche alle "4:pathl...e"-Blöcke ab filesIdx
let pathIdx = filesIdx;
while ((pathIdx = str.indexOf('4:pathl', pathIdx)) !== -1) {
pathIdx += 7; // nach '4:pathl'
// Es können mehrere Strings (Verzeichnisse + Dateiname) folgen, jeweils als "<len>:<name>"
let nameParts = [];
while (str[pathIdx] >= '0' && str[pathIdx] <= '9') {
// Lese Längenangabe
let lenStr = '';
while (str[pathIdx] >= '0' && str[pathIdx] <= '9') {
lenStr += str[pathIdx++];
}
if (str[pathIdx] !== ':') break;
pathIdx++;
let len = parseInt(lenStr, 10);
let name = str.substr(pathIdx, len);
nameParts.push(name);
pathIdx += len;
}
if (nameParts.length > 0) fileList.push(nameParts.join('/'));
// Suche nächstes '4:pathl' ab aktueller Position
}
return fileList;
} else {
// Single-File Torrent: nur Name
let nameIdx = str.indexOf('4:name', infoIdx);
if (nameIdx !== -1) {
let lenStr = '';
let idx = nameIdx + 6;
while (str[idx] >= '0' && str[idx] <= '9') {
lenStr += str[idx++];
}
if (str[idx] === ':') {
idx++;
let len = parseInt(lenStr, 10);
let name = str.substr(idx, len);
return [name];
}
}
}
} catch (e) {}
return [];
}
// --- Klicks abfangen ---
document.addEventListener('click', function(e) {
let el = e.target.closest('a');
if (!el) return;
// Magnet-Link
if (el.href.startsWith('magnet:?')) {
e.preventDefault();
showPathDialog(function(dir) {
sendToTransmission(el.href, true, dir, false);
});
}
// .torrent-Datei
if (el.href.match(/\.torrent($|\?)/i)) {
e.preventDefault();
GM_xmlhttpRequest({
method: 'GET',
url: el.href,
responseType: 'arraybuffer',
onload: function(response) {
const arrayBuffer = response.response;
const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
const fileList = parseTorrentFileList(arrayBuffer);
showPathDialog(function(dir, selectedFiles) {
// selectedFiles: Array von bools, true = gewünscht
// Sende an Transmission, und setze unwanted falls nötig
getConfig().then(config => {
getSessionId(config, function(sessionId) {
let headers = {
'Content-Type': 'application/json',
'X-Transmission-Session-Id': sessionId
};
if (config.user) {
headers['Authorization'] = 'Basic ' + btoa(config.user + ':' + config.pass);
}
let args = { metainfo: base64 };
if (dir) args['download-dir'] = dir;
let body = {
method: 'torrent-add',
arguments: args
};
GM_xmlhttpRequest({
method: 'POST',
url: config.url,
headers: headers,
data: JSON.stringify(body),
onload: function(response) {
let respObj = {};
try { respObj = JSON.parse(response.responseText); } catch (e) {}
if (response.status === 409) {
const match = response.responseHeaders.match(/x-transmission-session-id:(.+)/i);
const newSessionId = match ? match[1].trim() : null;
if (newSessionId) {
headers['X-Transmission-Session-Id'] = newSessionId;
GM_xmlhttpRequest({
method: 'POST',
url: config.url,
headers: headers,
data: JSON.stringify(body),
onload: function(resp2) {
let respObj2 = {};
try { respObj2 = JSON.parse(resp2.responseText); } catch (e) {}
if (resp2.status === 409) {
showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + resp2.responseHeaders);
} else {
showToast('An Transmission gesendet!');
// Nach dem Hinzufügen: unwanted setzen
if (Array.isArray(selectedFiles) && fileList.length === selectedFiles.length) {
let unwanted = [];
for (let i = 0; i < selectedFiles.length; i++) {
if (!selectedFiles[i]) unwanted.push(i);
}
if (respObj2.result === 'success' && respObj2.arguments && respObj2.arguments['torrent-added']) {
let tid = respObj2.arguments['torrent-added'].id;
setUnwantedFiles(tid, unwanted, config);
}
}
}
},
onerror: function() {
showToast('Fehler beim Senden an Transmission!');
}
});
} else {
showToast('Session-ID konnte nicht erneuert werden!\nResponse Headers:\n' + response.responseHeaders);
}
} else {
showToast('An Transmission gesendet!');
// Nach dem Hinzufügen: unwanted setzen
if (Array.isArray(selectedFiles) && fileList.length === selectedFiles.length) {
let unwanted = [];
for (let i = 0; i < selectedFiles.length; i++) {
if (!selectedFiles[i]) unwanted.push(i);
}
if (respObj.result === 'success' && respObj.arguments && respObj.arguments['torrent-added']) {
let tid = respObj.arguments['torrent-added'].id;
setUnwantedFiles(tid, unwanted, config);
}
}
}
},
onerror: function() {
showToast('Fehler beim Senden an Transmission!');
}
});
});
});
}, undefined, fileList);
},
onerror: function() {
showToast('Fehler beim Herunterladen der .torrent-Datei!');
}
});
}
}, true);
// --- Menüeinträge ---
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand('Transmission konfigurieren', askForConfig);
GM_registerMenuCommand('Download-Pfade verwalten', editPathsMenu);
}
// --- Rückmeldungen als Toast ---
function showToast(msg) {
let toast = document.createElement('div');
toast.textContent = msg;
toast.style.position = 'fixed';
toast.style.bottom = '32px';
toast.style.right = '32px';
toast.style.background = '#222';
toast.style.color = '#fff';
toast.style.padding = '12px 24px';
toast.style.borderRadius = '8px';
toast.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
toast.style.zIndex = 999999;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.transition = 'opacity 0.5s';
toast.style.opacity = 0;
setTimeout(() => toast.remove(), 500);
}, 2200);
}
})();