Greasy Fork is available in English.
Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality.
当前为
// ==UserScript== // @name Twitter/X Media Batch Downloader // @description Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality. // @icon data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHZlcnNpb249IjEuMSIKICAgIGlkPSJMYXllcl8xIiB3aWR0aD0iNTEycHgiIGhlaWdodD0iNTEycHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMjQgMjQ7IgogICAgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CiAgICA8ZyBmaWxsPSIjMWRhMGYxIj4KICAgICAgICA8cG9seWdvbgogICAgICAgICAgICBwb2ludHM9IjEyLjE1Mzk5MiwxMC43Mjk1NTMgOC4wODg2ODQsNS4wNDExOTkgNS45MjA0MSw1LjA0MTE5OSAxMC45NTYyOTksMTIuMDg3MDk3IDExLjU5MDIxLDEyLjk3MzQ1IDE1LjkwMDYzNSwxOS4wMDk1ODMgMTguMDY4OTA5LDE5LjAwOTU4MyAxMi43ODUyMTcsMTEuNjE1OTA2ICAiIC8+CiAgICAgICAgPHBhdGgKICAgICAgICAgICAgZD0iTTIxLjE1OTc5LDFIMi44NDAyMUMxLjgyMzg1MywxLDEsMS44MjM4NTMsMSwyLjg0MDIxdjE4LjMxOTU4QzEsMjIuMTc2MTQ3LDEuODIzODUzLDIzLDIuODQwMjEsMjNoMTguMzE5NTggICBDMjIuMTc2MTQ3LDIzLDIzLDIyLjE3NjE0NywyMywyMS4xNTk3OVYyLjg0MDIxQzIzLDEuODIzODUzLDIyLjE3NjE0NywxLDIxLjE1OTc5LDF6IE0xNS4yMzUzNTIsMjBsLTQuMzYyNTQ5LTYuMjEzMDEzICAgTDUuNDExNDM4LDIwSDRsNi4yNDY4ODctNy4xMDQ2NzVMNCw0aDQuNzY0NjQ4bDQuMTMwMTI3LDUuODgxOTU4TDE4LjA2OTU4LDRoMS40MTEzNzdsLTUuOTU2OTcsNi43NzU2MzVMMjAsMjBIMTUuMjM1MzUyeiIgLz4KICAgIDwvZz4KPC9zdmc+Cg== // @namespace https://xbatch.online // @supportURL https://www.patreon.com/exyezed // @homepageURL https://www.patreon.com/exyezed // @version 5.4 // @author afkarxyz // @antifeature payment Unlock access to the Twitter/X Media Batch Downloader script by becoming a paid member! Join the membership to receive your Patreon auth code. // @license MIT // @match https://twitter.com/* // @match https://x.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @require https://cdn.jsdelivr.net/npm/[email protected]/dayjs.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/dexie.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/dexie-export-import.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/preact.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/hooks/dist/hooks.umd.js // @require https://cdn.jsdelivr.net/npm/@preact/[email protected]/dist/signals-core.min.js // @connect api.xbatch.online // @connect backup.xbatch.online // @connect pbs.twimg.com // @connect video.twimg.com // ==/UserScript== (function () { "use strict"; const { h, render } = preact; const { useState, useEffect, useRef } = preactHooks; const { signal, effect } = preactSignalsCore; const ICONS = { download: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download-icon lucide-download"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>`, hardDriveDownload: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hard-drive-download-icon lucide-hard-drive-download"><path d="M12 2v8"/><path d="m16 6-4 4-4-4"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 18h.01"/><path d="M10 18h.01"/></svg>`, cloudCheck: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-check-icon lucide-cloud-check"><path d="m17 15-5.5 5.5L9 18"/><path d="M5 17.743A7 7 0 1 1 15.71 10h1.79a4.5 4.5 0 0 1 1.5 8.742"/></svg>`, send: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send-icon lucide-send"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>`, layers: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layers-icon lucide-layers"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>`, betweenHorizontal: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-between-horizontal-start-icon lucide-between-horizontal-start"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>`, play: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-play-icon lucide-play"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/></svg>`, stop: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-icon lucide-square"><rect width="18" height="18" x="3" y="3" rx="2"/></svg>`, images: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-images-icon lucide-images"><path d="m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16"/><path d="M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2"/><circle cx="13" cy="7" r="1" fill="currentColor"/><rect x="8" y="2" width="14" height="14" rx="2"/></svg>`, twitter: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-twitter-icon lucide-twitter"><path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"/></svg>`, rotateKey: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw-key-icon lucide-rotate-ccw-key"><path d="m14.5 9.5 1 1"/><path d="m15.5 8.5-4 4"/><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><circle cx="10" cy="14" r="2"/></svg>`, checkCircle: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-big-icon lucide-circle-check-big"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></svg>`, circleX: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`, rabbit: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rabbit-icon lucide-rabbit"><path d="M13 16a3 3 0 0 1 2.24 5"/><path d="M18 12h.01"/><path d="M18 21h-8a4 4 0 0 1-4-4 7 7 0 0 1 7-7h.2L9.6 6.4a1 1 0 1 1 2.8-2.8L15.8 7h.2c3.3 0 6 2.7 6 6v1a2 2 0 0 1-2 2h-1a3 3 0 0 0-3 3"/><path d="M20 8.54V4a2 2 0 1 0-4 0v3"/><path d="M7.612 12.524a3 3 0 1 0-1.6 4.3"/></svg>`, authTokenIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-key-icon lucide-key"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/></svg>`, patreonAuthIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock-icon lucide-lock"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`, patreonAuthUnlockIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock-open-icon lucide-lock-open"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>`, shieldCheck: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-check-icon lucide-shield-check"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>`, shieldX: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-x-icon lucide-shield-x"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m14.5 9.5-5 5"/><path d="m9.5 9.5 5 5"/></svg>`, cloudDownload: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-download-icon lucide-cloud-download"><path d="M12 13v8l-4-4"/><path d="m12 21 4-4"/><path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284"/></svg>`, spinner: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle-icon lucide-loader-circle"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`, sun: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun-icon lucide-sun"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>`, moon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon-icon lucide-moon"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>`, close: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`, eye: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye-icon lucide-eye"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>`, eyeOff: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye-off-icon lucide-eye-off"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>`, alert: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-alert-icon lucide-circle-alert"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>`, triangleAlert: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`, notepadText: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-notepad-text-icon lucide-notepad-text"><path d="M8 2v4"/><path d="M12 2v4"/><path d="M16 2v4"/><rect width="16" height="18" x="4" y="4" rx="2"/><path d="M8 10h6"/><path d="M8 14h8"/><path d="M8 18h5"/></svg>`, undo: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-undo2-icon lucide-undo-2"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>`, server: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-server-icon lucide-server"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></svg>`, photo: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image-icon lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`, video: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-video-icon lucide-video"><path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/></svg>`, animatedGif: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image-play-icon lucide-image-play"><path d="M15 15.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997a1 1 0 0 1-1.517-.86z"/><path d="M21 12.17V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/><path d="m6 21 5-5"/><circle cx="9" cy="9" r="2"/></svg>`, trash: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`, database: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database-icon lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>`, chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left-icon lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>`, chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>`, chevronsLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left-icon lucide-chevrons-left"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>`, chevronsRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-right-icon lucide-chevrons-right"><path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/></svg>`, shredder: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shredder-icon lucide-shredder"><path d="M10 22v-5"/><path d="M14 19v-2"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M18 20v-3"/><path d="M2 13h20"/><path d="M20 13V7l-5-5H6a2 2 0 0 0-2 2v9"/><path d="M6 20v-3"/></svg>`, frown: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-frown-icon lucide-frown"><circle cx="12" cy="12" r="10"/><path d="M16 16s-1.5-2-4-2-4 2-4 2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>`, upload: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-upload-icon lucide-upload"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></svg>`, resetIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw-icon lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>`, fileOutput: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-output-icon lucide-file-output"><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M4 7V4a2 2 0 0 1 2-2 2 2 0 0 0-2 2"/><path d="M4.063 20.999a2 2 0 0 0 2 1L18 22a2 2 0 0 0 2-2V7l-5-5H6"/><path d="m5 11-3 3"/><path d="m5 17-3-3h10"/></svg>`, fileInput: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-input-icon lucide-file-input"><path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M2 15h10"/><path d="m9 18 3-3-3-3"/></svg>`, }; const db = new Dexie("TwitterXMediaBatchDownloader"); db.version(1).stores({ settings: "key", mediaData: "username, data, timestamp", }); db.version(2) .stores({ settings: "key", mediaData: "cacheKey, username, timelineType, mediaType, data, timestamp", }) .upgrade((tx) => { return tx.mediaData.toCollection().modify((item) => { item.cacheKey = `${item.username}_media_all`; item.timelineType = "media"; item.mediaType = "all"; }); }); const state = { isModalOpen: signal(false), activeTab: signal("dashboard"), authToken: signal(""), patreonAuth: signal(""), isVerified: signal(false), isLoading: signal(false), mediaData: signal(null), error: signal(null), errorType: signal("general"), success: signal(null), theme: signal("light"), downloadProgress: signal(0), currentUsername: signal(""), downloadedFiles: signal(0), totalFileSize: signal(0), selectedApi: signal("default"), fetchMode: signal("fresh"), selectedCacheUser: signal(null), cacheMediaPage: signal(1), mediaType: signal("all"), timelineType: signal("media"), isDownloading: signal(false), isDownloadingCurrent: signal(false), fetchType: signal("single"), batchSize: signal(100), startingBatch: signal(0), currentBatchPage: signal(0), isAutoBatch: signal(false), batchedMediaData: signal([]), currentBatchData: signal([]), loadingDirection: signal(null), concurrentLimit: signal(20), showBatchDatabase: signal(false), loadedFromDatabase: signal(false), loadedDatabaseConfig: signal(null), previewModalOpen: signal(false), previewMediaData: signal(null), previewCurrentIndex: signal(0), previewFilters: signal({ photo: false, video: false, animatedGif: false, }), }; async function loadSettings() { try { const authTokenDoc = await db.settings.get("authToken"); const patreonAuthDoc = await db.settings.get("patreonAuth"); const isVerifiedDoc = await db.settings.get("isVerified"); const themeDoc = await db.settings.get("theme"); const selectedApiDoc = await db.settings.get("selectedApi"); const mediaTypeDoc = await db.settings.get("mediaType"); const timelineTypeDoc = await db.settings.get("timelineType"); const batchSizeDoc = await db.settings.get("batchSize"); const startingBatchDoc = await db.settings.get("startingBatch"); const concurrentLimitDoc = await db.settings.get("concurrentLimit"); const showBatchDatabaseDoc = await db.settings.get("showBatchDatabase"); if (authTokenDoc) state.authToken.value = authTokenDoc.value; if (patreonAuthDoc) state.patreonAuth.value = patreonAuthDoc.value; if (isVerifiedDoc) state.isVerified.value = isVerifiedDoc.value; if (themeDoc) state.theme.value = themeDoc.value; if (selectedApiDoc) state.selectedApi.value = selectedApiDoc.value; if (mediaTypeDoc) state.mediaType.value = mediaTypeDoc.value; if (timelineTypeDoc) state.timelineType.value = timelineTypeDoc.value; if (batchSizeDoc) state.batchSize.value = batchSizeDoc.value; if (startingBatchDoc) state.startingBatch.value = startingBatchDoc.value; if (concurrentLimitDoc) state.concurrentLimit.value = concurrentLimitDoc.value; if (showBatchDatabaseDoc) state.showBatchDatabase.value = showBatchDatabaseDoc.value; } catch (error) { console.error("Failed to load settings:", error); } } async function saveSettings() { try { await db.settings.bulkPut([ { key: "authToken", value: state.authToken.value }, { key: "patreonAuth", value: state.patreonAuth.value }, { key: "isVerified", value: state.isVerified.value }, { key: "theme", value: state.theme.value }, { key: "selectedApi", value: state.selectedApi.value }, { key: "mediaType", value: state.mediaType.value }, { key: "timelineType", value: state.timelineType.value }, { key: "batchSize", value: state.batchSize.value }, { key: "startingBatch", value: state.startingBatch.value }, { key: "concurrentLimit", value: state.concurrentLimit.value }, { key: "showBatchDatabase", value: state.showBatchDatabase.value }, ]); } catch (error) { console.error("Failed to save settings:", error); } } const styles = ` .tmd-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); z-index: 9999; display: flex; align-items: center; justify-content: center; animation: fadeIn 0.2s ease-out; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .tmd-modal { width: 90%; max-width: 600px; max-height: 80vh; border-radius: 12px; overflow: hidden; animation: slideUp 0.3s ease-out; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); } .tmd-modal.dark { background: hsl(240 5.9% 10%); color: hsl(240 4.8% 95.9%); border: 1px solid hsl(240 5% 40% / 0.5); box-shadow: 0 0 0 1px hsl(240 5% 35% / 0.2), 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2); } .tmd-modal.light { background: white; color: hsl(240 5.9% 10%); border: 1px solid hsl(240 5.9% 90%); } .tmd-header { padding: 20px; border-bottom: 1px solid; display: flex; justify-content: space-between; align-items: center; } .dark .tmd-header { border-color: hsl(240 3.7% 15.9%); } .light .tmd-header { border-color: hsl(240 5.9% 90%); } .tmd-header-title { font-size: 18px; font-weight: 600; color: hsl(204.17deg 87.55% 52.75%); } .tmd-header-controls { display: flex; gap: 8px; align-items: center; } .tmd-theme-toggle { padding: 8px; border-radius: 8px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; } .dark .tmd-theme-toggle { background: hsl(240 3.7% 15.9%); } .dark .tmd-theme-toggle:hover { background: hsl(240 5.3% 26.1%); } .light .tmd-theme-toggle { background: hsl(240 5.9% 95%); } .light .tmd-theme-toggle:hover { background: hsl(240 5.9% 90%); } .tmd-reset-toggle { color: inherit; } .dark .tmd-reset-toggle:hover { background: hsl(37.7deg 92.1% 50.2% / 0.2); color: hsl(37.7deg 92.1% 50.2%); } .light .tmd-reset-toggle:hover { background: hsl(37.7deg 92.1% 50.2% / 0.1); color: hsl(37.7deg 92.1% 50.2%); } .tmd-preview-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.95); z-index: 10000; display: flex; flex-direction: column; animation: fadeIn 0.3s ease-out; } .tmd-preview-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(10px); border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .tmd-preview-account-info { display: flex; align-items: center; gap: 12px; color: white; } .tmd-preview-profile-img { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(255, 255, 255, 0.2); } .tmd-preview-account-details h3 { margin: 0; font-size: 16px; font-weight: 600; } .tmd-preview-account-details p { margin: 2px 0 0 0; font-size: 14px; opacity: 0.7; } .tmd-preview-stats { display: flex; gap: 16px; font-size: 12px; opacity: 0.8; } .tmd-preview-close { background: rgba(255, 255, 255, 0.1); border: none; color: white; padding: 8px; border-radius: 8px; cursor: pointer; transition: all 0.2s; } .tmd-preview-close:hover { background: hsl(0 84.2% 60.2% / 0.2); color: hsl(0 84.2% 60.2%); } .tmd-preview-content { flex: 1; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; } .tmd-preview-media { max-width: 90%; max-height: 90%; object-fit: contain; user-select: none; border-radius: 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); } .tmd-preview-nav { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(0, 0, 0, 0.7); border: none; color: white; padding: 12px; border-radius: 50%; cursor: pointer; transition: all 0.2s; z-index: 10001; } .tmd-preview-nav:hover { background: rgba(0, 0, 0, 0.9); transform: translateY(-50%) scale(1.1); } .tmd-preview-nav:disabled { opacity: 0.3; cursor: not-allowed; } .tmd-preview-nav:disabled:hover { transform: translateY(-50%); } .tmd-preview-nav-prev { left: 20px; } .tmd-preview-nav-next { right: 20px; } .tmd-preview-counter { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 8px 16px; border-radius: 20px; font-size: 14px; backdrop-filter: blur(10px); } .tmd-preview-content { touch-action: pan-y; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; -webkit-touch-callout: none; } .tmd-preview-media { pointer-events: none; -webkit-user-drag: none; -khtml-user-drag: none; -moz-user-drag: none; -o-user-drag: none; user-drag: none; } .tmd-preview-content video { pointer-events: auto; } @media (max-width: 768px) { .tmd-preview-content { touch-action: manipulation; } .tmd-preview-modal { touch-action: manipulation; } } .tmd-preview-nav:focus { outline: 2px solid rgba(255, 255, 255, 0.5); outline-offset: 2px; } .tmd-preview-close:focus { outline: 2px solid rgba(255, 255, 255, 0.5); outline-offset: 2px; } .tmd-preview-filters { display: flex; gap: 8px; align-items: center; } .tmd-preview-filter-bar { padding: 12px 20px; background: rgba(0, 0, 0, 0.8); border-bottom: 1px solid rgba(255, 255, 255, 0.1); display: flex; justify-content: center; align-items: center; gap: 12px; } .tmd-preview-filter-btn { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); color: white; padding: 4px 8px; border-radius: 6px; cursor: pointer; transition: all 0.2s; font-size: 11px; display: flex; align-items: center; gap: 4px; } .tmd-preview-filter-btn:hover { background: rgba(255, 255, 255, 0.2); } .tmd-preview-filter-btn.active { background: hsl(204.17deg 87.55% 52.75%); border-color: hsl(204.17deg 87.55% 52.75%); } @media (max-width: 768px) { .tmd-preview-header { padding: 12px 16px; } .tmd-preview-account-info { gap: 8px; } .tmd-preview-profile-img { width: 40px; height: 40px; } .tmd-preview-account-details h3 { font-size: 14px; } .tmd-preview-account-details p { font-size: 12px; } .tmd-preview-stats { gap: 12px; font-size: 11px; } .tmd-preview-nav { padding: 8px; } .tmd-preview-nav-prev { left: 10px; } .tmd-preview-nav-next { right: 10px; } .tmd-preview-counter { bottom: 10px; font-size: 12px; padding: 6px 12px; } } .tmd-close-btn { padding: 8px; border-radius: 8px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; } .dark .tmd-close-btn { background: hsl(240 3.7% 15.9%); } .dark .tmd-close-btn:hover { background: hsl(0deg 84.2% 60.2% / 0.2); } .dark .tmd-close-btn:hover svg { stroke: hsl(0deg 84.2% 60.2%); } .light .tmd-close-btn { background: hsl(240 5.9% 95%); } .light .tmd-close-btn:hover { background: hsl(0deg 84.2% 60.2% / 0.1); } .light .tmd-close-btn:hover svg { stroke: hsl(0deg 84.2% 60.2%); } .tmd-tabs { display: flex; padding: 0 20px; gap: 16px; border-bottom: 1px solid; } .dark .tmd-tabs { border-color: hsl(240 3.7% 15.9%); } .light .tmd-tabs { border-color: hsl(240 5.9% 90%); } .tmd-tab { padding: 12px 0; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; font-weight: 500; } .dark .tmd-tab { color: hsl(240 5% 64.9%); } .light .tmd-tab { color: hsl(240 3.8% 46.1%); } .tmd-tab:hover { color: hsl(204.17deg 87.55% 52.75%); } .tmd-tab.active { color: hsl(204.17deg 87.55% 52.75%); border-bottom-color: hsl(204.17deg 87.55% 52.75%); } .tmd-content { padding: 20px; min-height: 150px; max-height: calc(80vh - 150px); overflow-y: auto; display: flex; flex-direction: column; } .tmd-input-group { margin-bottom: 20px; } .tmd-label { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-weight: 500; } .tmd-input { width: 100%; padding: 10px 12px; padding-right: 40px; border-radius: 8px; border: 1px solid; font-size: 14px; transition: all 0.2s; font-family: monospace; box-sizing: border-box; } .tmd-input-wrapper { position: relative; width: 100%; } .tmd-input-toggle { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); cursor: pointer; padding: 4px; display: flex; align-items: center; justify-content: center; opacity: 0.5; transition: opacity 0.2s; } .tmd-input-toggle:hover { opacity: 1; } .dark .tmd-input { background: hsl(240 3.7% 15.9%); border-color: hsl(240 5.3% 26.1%); color: hsl(240 4.8% 95.9%); } .dark .tmd-input:focus { border-color: hsl(204.17deg 87.55% 52.75%); outline: none; } .light .tmd-input { background: white; border-color: hsl(240 5.9% 90%); color: hsl(240 5.9% 10%); } .light .tmd-input:focus { border-color: hsl(204.17deg 87.55% 52.75%); outline: none; } .tmd-button { padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; margin: 0; } .tmd-button-container { display: flex; justify-content: center; margin-top: 15px; } .tmd-button-primary { background: hsl(204.17deg 87.55% 52.75%); color: white; } .tmd-button-primary:hover { background: hsl(204.17deg 87.55% 45%); } .tmd-button-primary:disabled { opacity: 0.5; cursor: not-allowed; } .tmd-button-secondary { background: hsl(142.1deg 76.2% 36.3%); color: white; } .tmd-button-secondary:hover { background: hsl(142.1deg 76.2% 30%); } .tmd-button-secondary:disabled { opacity: 0.5; cursor: not-allowed; } .tmd-button-outline { background: transparent; border: 1px solid; } .dark .tmd-button-outline { border-color: hsl(240 5.3% 26.1%); color: hsl(240 4.8% 95.9%); } .dark .tmd-button-outline:hover { background: hsl(240 3.7% 15.9%); } .light .tmd-button-outline { border-color: hsl(240 5.9% 85%); color: hsl(240 5.9% 10%); } .light .tmd-button-outline:hover { background: hsl(240 5.9% 95%); } .tmd-button-outline:disabled { opacity: 0.5; cursor: not-allowed; } .tmd-button-outline:not(:disabled):hover { background: hsl(240 3.7% 15.9%); } .dark .tmd-button-outline:not(:disabled):hover { background: hsl(240 5.3% 26.1%); border-color: hsl(240 5.3% 35%); } .light .tmd-button-outline:not(:disabled):hover { background: hsl(240 5.9% 90%); border-color: hsl(240 5.9% 70%); } .tmd-spinner { animation: spin 1s linear infinite; } .tmd-error { padding: 12px; border-radius: 8px; margin-bottom: 20px; display: flex; align-items: center; gap: 8px; } .tmd-error.auth { background: hsl(45deg 100% 51% / 0.1); color: hsl(45deg 100% 45%); } .tmd-error.api, .tmd-error.username { background: hsl(45deg 100% 51% / 0.1); color: hsl(45deg 100% 45%); } .tmd-error.general { background: hsl(0deg 84.2% 60.2% / 0.1); color: hsl(0deg 84.2% 60.2%); } .tmd-error.failed { background: hsl(0deg 84.2% 60.2% / 0.1); color: hsl(0deg 84.2% 60.2%); } .tmd-error-icon { flex-shrink: 0; display: flex; align-items: center; } .tmd-success { padding: 12px; border-radius: 8px; background: hsl(142.1deg 76.2% 36.3% / 0.1); color: hsl(142.1deg 76.2% 36.3%); margin-bottom: 20px; display: flex; align-items: flex-start; gap: 8px; } .tmd-success-icon { flex-shrink: 0; display: flex; align-items: center; margin-top: 2px; } .tmd-info-card { padding: 16px; border-radius: 8px; margin-bottom: 20px; } .dark .tmd-info-card { background: hsl(240 3.7% 15.9%); border: 1px solid hsl(240 5.3% 26.1%); } .light .tmd-info-card { background: hsl(240 4.8% 95.9%); border: 1px solid hsl(240 5.9% 90%); } .tmd-info-card.clickable { transition: all 0.2s ease; cursor: default; position: relative; z-index: 1; } .tmd-info-card.clickable:hover { z-index: 10; } .dark .tmd-info-card.clickable:hover { background: hsl(240 3.7% 18%); border-color: hsl(240 5.3% 30%); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .light .tmd-info-card.clickable:hover { background: hsl(240 4.8% 98%); border-color: hsl(240 5.9% 80%); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .tmd-info-row { display: flex; justify-content: space-between; margin-bottom: 8px; } .tmd-info-row:last-child { margin-bottom: 0; } .tmd-info-label { font-weight: 500; } .tmd-progress-bar { width: 100%; height: 8px; border-radius: 4px; overflow: hidden; margin-bottom: 0; } .dark .tmd-progress-bar { background: hsl(240 3.7% 15.9%); } .light .tmd-progress-bar { background: hsl(240 5.9% 90%); } .tmd-progress-fill { height: 100%; background: linear-gradient(90deg, hsl(204.17deg 87.55% 45%), hsl(204.17deg 87.55% 52.75%) ); transition: width 0.3s ease; } .tmd-progress-info { display: flex; justify-content: space-between; margin-bottom: 20px; font-size: 14px; } .dark .tmd-progress-info { color: hsl(240 4.8% 95.9%); } .light .tmd-progress-info { color: hsl(240 5.9% 10%); } .dl-icon { display: inline-flex; margin-left: 6px; padding: 4px; border-radius: 4px; transition: all 0.2s; cursor: pointer; } .tmd-radio-group { display: flex; gap: 20px; margin-top: 8px; } .tmd-radio-item { display: flex; align-items: center; gap: 8px; cursor: pointer; } .tmd-radio { width: 20px; height: 20px; border-radius: 50%; border: 2px solid; position: relative; transition: all 0.2s; } .dark .tmd-radio { border-color: hsl(240 5.3% 26.1%); background: hsl(240 3.7% 15.9%); } .light .tmd-radio { border-color: hsl(240 5.9% 85%); background: white; } .tmd-radio.checked { border-color: hsl(204.17deg 87.55% 52.75%); } .tmd-radio.checked::after { content: ''; position: absolute; width: 10px; height: 10px; border-radius: 50%; background: hsl(204.17deg 87.55% 52.75%); top: 50%; left: 50%; transform: translate(-50%, -50%); } .tmd-radio-label { font-size: 14px; user-select: none; } .tmd-button-square { width: 40px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 8px; flex-shrink: 0; } .tmd-icon-button { background: transparent; border: none; padding: 6px; cursor: pointer; border-radius: 6px; transition: all 0.3s ease; display: inline-flex; align-items: center; justify-content: center; opacity: 0.7; } .tmd-icon-button:hover { opacity: 1; background: hsl(0deg 84.2% 60.2% / 0.1); } .tmd-icon-button:hover svg { stroke: hsl(0deg 84.2% 60.2%); transition: stroke 0.3s ease; } .tmd-delete-button { transition: all 0.3s ease; } .tmd-delete-button:hover { background: hsl(0deg 84.2% 60.2% / 0.1) !important; border-color: hsl(0deg 84.2% 60.2%) !important; } .tmd-delete-button:hover svg { stroke: hsl(0deg 84.2% 60.2%); transition: stroke 0.3s ease; } .tmd-load-button { transition: all 0.3s ease; } .tmd-load-button:hover { background: hsl(142.1deg 76.2% 36.3% / 0.1) !important; border-color: hsl(142.1deg 76.2% 36.3%) !important; color: hsl(142.1deg 76.2% 36.3%) !important; } .tmd-load-button:hover svg { stroke: hsl(142.1deg 76.2% 36.3%); transition: stroke 0.3s ease; } .tmd-download-current-button { transition: all 0.3s ease; } .tmd-download-current-button:hover { background: hsl(142.1deg 76.2% 36.3% / 0.1) !important; border-color: hsl(142.1deg 76.2% 36.3%) !important; } .tmd-download-current-button:hover svg { stroke: hsl(142.1deg 76.2% 36.3%); transition: stroke 0.3s ease; } .tmd-shred-button { transition: all 0.3s ease; } .tmd-shred-button:hover { color: hsl(0deg 84.2% 60.2%) !important; border-color: hsl(0deg 84.2% 60.2%) !important; background: hsl(0deg 84.2% 60.2% / 0.1) !important; } .tmd-shred-button:hover svg { stroke: hsl(0deg 84.2% 60.2%); transition: stroke 0.3s ease; } .tmd-export-button { transition: all 0.3s ease; } .tmd-export-button:hover { background: hsl(204.17deg 87.55% 52.75% / 0.1) !important; color: hsl(204.17deg 87.55% 52.75%) !important; border-color: hsl(204.17deg 87.55% 52.75%) !important; } .tmd-export-button:hover svg { stroke: hsl(204.17deg 87.55% 52.75%); transition: stroke 0.3s ease; } .tmd-import-button { transition: all 0.3s ease; } .tmd-import-button:hover { background: hsl(270deg 60% 50% / 0.1) !important; color: hsl(270deg 60% 50%) !important; border-color: hsl(270deg 60% 50%) !important; } .tmd-import-button:hover svg { stroke: hsl(270deg 60% 50%); transition: stroke 0.3s ease; } .tmd-preview-button { transition: all 0.3s ease; } .tmd-preview-button:hover { background: hsl(204.17deg 87.55% 52.75% / 0.1) !important; border-color: hsl(204.17deg 87.55% 52.75%) !important; } .tmd-preview-button:hover svg { stroke: hsl(204.17deg 87.55% 52.75%); transition: stroke 0.3s ease; } .tmd-download-single-button { transition: all 0.3s ease; } .tmd-download-single-button:hover { background: hsl(142.1deg 76.2% 36.3% / 0.1) !important; border-color: hsl(142.1deg 76.2% 36.3%) !important; } .tmd-download-single-button:hover svg { stroke: hsl(142.1deg 76.2% 36.3%); transition: stroke 0.3s ease; } .tmd-batch-controls { display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px; } .tmd-batch-controls-row { display: flex; gap: 8px; justify-content: center; } .tmd-button-stop:not(:disabled):hover { background: hsl(0deg 84.2% 60.2% / 0.1) !important; border-color: hsl(0deg 84.2% 60.2%) !important; color: hsl(0deg 84.2% 60.2%) !important; } .tmd-button-stop:not(:disabled):hover svg { stroke: hsl(0deg 84.2% 60.2%); } .tmd-button-start:not(:disabled):hover { background: hsl(142.1deg 76.2% 36.3% / 0.1) !important; border-color: hsl(142.1deg 76.2% 36.3%) !important; color: hsl(142.1deg 76.2% 36.3%) !important; } .tmd-button-start:not(:disabled):hover svg { stroke: hsl(142.1deg 76.2% 36.3%); } .tmd-tweet-link { text-decoration: none; cursor: pointer; transition: all 0.2s; } .tmd-tweet-link:hover { opacity: 0.8; text-decoration: underline; filter: brightness(1.2); } .tmd-filter-button { transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; } .tmd-filter-button.tmd-filter-photo:hover { background: hsl(142.1deg 76.2% 36.3% / 0.1) !important; border-color: hsl(142.1deg 76.2% 36.3%) !important; } .tmd-filter-button.tmd-filter-photo:hover svg { stroke: hsl(142.1deg 76.2% 36.3%); } .tmd-filter-button.tmd-filter-video:hover { background: hsl(37.7deg 92.1% 50.2% / 0.1) !important; border-color: hsl(37.7deg 92.1% 50.2%) !important; } .tmd-filter-button.tmd-filter-video:hover svg { stroke: hsl(37.7deg 92.1% 50.2%); } .tmd-filter-button.tmd-filter-gif:hover { background: hsl(270deg 60% 50% / 0.1) !important; border-color: hsl(270deg 60% 50%) !important; } .tmd-filter-button.tmd-filter-gif:hover svg { stroke: hsl(270deg 60% 50%); } .tmd-alert-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); z-index: 10000; display: flex; align-items: center; justify-content: center; animation: fadeIn 0.2s ease-out; } .tmd-alert { background: white; color: hsl(240 5.9% 10%); border-radius: 12px; padding: 24px; max-width: 400px; width: 90%; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); animation: slideUp 0.3s ease-out; } .tmd-alert.dark { background: hsl(240 5.9% 10%); color: hsl(240 4.8% 95.9%); border: 1px solid hsl(240 3.7% 15.9%); } .dark .tmd-alert { background: hsl(240 5.9% 10%); color: hsl(240 4.8% 95.9%); border: 1px solid hsl(240 3.7% 15.9%); } .tmd-alert-title { font-size: 18px; font-weight: 600; margin-bottom: 12px; } .tmd-alert-message { margin-bottom: 20px; opacity: 0.9; } .tmd-alert-buttons { display: flex; gap: 12px; justify-content: flex-end; } .tmd-alert-button { padding: 8px 16px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none; } .tmd-alert-button-cancel { background: transparent; border: 1px solid; } .dark .tmd-alert-button-cancel { border-color: hsl(240 5.3% 26.1%); color: hsl(240 4.8% 95.9%); } .dark .tmd-alert-button-cancel:hover { background: hsl(240 3.7% 15.9%); } .light .tmd-alert-button-cancel { border-color: hsl(240 5.9% 85%); color: hsl(240 5.9% 10%); } .light .tmd-alert-button-cancel:hover { background: hsl(240 5.9% 95%); } .tmd-alert-button-confirm { background: hsl(0deg 84.2% 60.2%); color: white; } .tmd-alert-button-confirm:hover { background: hsl(0deg 84.2% 50%); } input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } input[type="number"] { -moz-appearance: textfield; } .tmd-media-list-container { flex: 1; overflow-y: auto; overflow-x: hidden; margin-bottom: 16px; padding: 2px; position: relative; } .tmd-database-content { display: flex; flex-direction: column; height: 100%; } .tmd-media-list-wrapper { flex: 1; display: flex; flex-direction: column; min-height: 0; } @media (max-width: 480px) { .tmd-service-data-row { flex-direction: column !important; gap: 20px !important; } .tmd-service-data-row > div { flex: none !important; width: 100% !important; } .tmd-content { max-height: calc(100vh - 180px); padding: 15px; } .tmd-modal { max-height: 90vh; } .tmd-media-list-container { max-height: calc(100vh - 380px); } .tmd-download-current-button, .tmd-button-secondary { padding: 8px 12px !important; font-size: 13px !important; white-space: nowrap !important; min-width: auto !important; } .tmd-download-current-button span, .tmd-button-secondary span { font-size: 13px !important; } .tmd-download-current-button svg, .tmd-button-secondary svg { width: 16px !important; height: 16px !important; } .tmd-button-container .tmd-button-primary, .tmd-button-container .tmd-button-secondary { padding: 8px 12px !important; font-size: 13px !important; white-space: nowrap !important; min-width: auto !important; flex: 1 !important; max-width: 150px !important; } .tmd-button-container { display: flex !important; gap: 8px !important; justify-content: center !important; } .tmd-button-primary span, .tmd-button-secondary span { font-size: 13px !important; } .tmd-button-primary svg, .tmd-button-secondary svg { width: 16px !important; height: 16px !important; } .tmd-database-content > div:first-child { flex-wrap: wrap !important; justify-content: flex-start !important; } .tmd-database-content .tmd-button-outline:not(.tmd-button-square) { padding: 6px 10px !important; font-size: 12px !important; min-width: auto !important; } .tmd-database-content .tmd-button-outline:not(.tmd-button-square) span { font-size: 12px !important; } .tmd-database-content .tmd-button-outline:not(.tmd-button-square) svg { width: 14px !important; height: 14px !important; } .tmd-database-content > div:first-child > div:last-child { margin-left: 0 !important; margin-top: 8px !important; width: 100% !important; justify-content: flex-start !important; } .tmd-database-content input[type="number"] { width: 45px !important; padding: 4px 6px !important; } } `; GM_addStyle(styles); function Modal() { const modalRef = useRef(null); const [showResetConfirm, setShowResetConfirm] = useState(false); useEffect(() => { function handleEscape(e) { const activeElement = document.activeElement; const isTyping = activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.tagName === "SELECT" || activeElement.contentEditable === "true"); if (e.key === "Escape" && state.isModalOpen.value && !isTyping) { state.isModalOpen.value = false; } } document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, []); const handleOverlayClick = (e) => { if (e.target === e.currentTarget) { const activeElement = document.activeElement; const isInputFocused = activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.tagName === "SELECT"); if (!isInputFocused) { state.isModalOpen.value = false; } } }; const toggleTheme = () => { state.theme.value = state.theme.value === "dark" ? "light" : "dark"; saveSettings(); }; const handleFactoryReset = async () => { try { await db.settings.clear(); await db.mediaData.clear(); state.authToken.value = ""; state.patreonAuth.value = ""; state.isVerified.value = false; state.theme.value = "light"; state.selectedApi.value = "default"; state.mediaType.value = "all"; state.timelineType.value = "media"; state.batchSize.value = 100; state.startingBatch.value = 0; state.concurrentLimit.value = 20; state.showBatchDatabase.value = false; state.mediaData.value = null; state.error.value = null; state.success.value = null; state.downloadProgress.value = 0; state.currentUsername.value = ""; state.downloadedFiles.value = 0; state.totalFileSize.value = 0; state.fetchMode.value = "fresh"; state.selectedCacheUser.value = null; state.cacheMediaPage.value = 1; state.isDownloading.value = false; state.isDownloadingCurrent.value = false; state.fetchType.value = "single"; state.currentBatchPage.value = 0; state.isAutoBatch.value = false; state.batchedMediaData.value = []; state.currentBatchData.value = []; state.loadingDirection.value = null; setShowResetConfirm(false); state.success.value = "Factory reset completed successfully!"; setTimeout(() => { if (state.success.value === "Factory reset completed successfully!") { state.success.value = null; } }, 2000); state.activeTab.value = "auth"; } catch (error) { console.error("Failed to perform factory reset:", error); state.error.value = "Failed to perform factory reset. Please try again."; state.errorType.value = "general"; setTimeout(() => { if ( state.error.value === "Failed to perform factory reset. Please try again." ) { state.error.value = null; } }, 2000); } }; if (!state.isModalOpen.value) return null; return h( "div", null, showResetConfirm && h(AlertDialog, { title: "Factory Reset", message: "WARNING: This will permanently delete ALL settings and cached data. The extension will be reset to its initial state. This action cannot be undone. Are you absolutely sure?", onConfirm: handleFactoryReset, onCancel: () => setShowResetConfirm(false), confirmLabel: "Reset", }), h(PreviewModal), h( "div", { className: "tmd-modal-overlay", onClick: handleOverlayClick, }, h( "div", { className: `tmd-modal ${state.theme.value}`, ref: modalRef, }, h( "div", { className: "tmd-header" }, h( "div", { className: "tmd-header-title" }, state.currentUsername.value ? `@${state.currentUsername.value}` : "No User Detected" ), h( "div", { className: "tmd-header-controls" }, h("div", { className: "tmd-theme-toggle", onClick: toggleTheme, dangerouslySetInnerHTML: { __html: state.theme.value === "dark" ? ICONS.sun : ICONS.moon, }, title: "Toggle theme", }), h("div", { className: "tmd-theme-toggle tmd-reset-toggle", onClick: () => setShowResetConfirm(true), dangerouslySetInnerHTML: { __html: ICONS.resetIcon }, title: "Factory reset - Clear all data and settings", }), h("div", { className: "tmd-close-btn", onClick: () => (state.isModalOpen.value = false), dangerouslySetInnerHTML: { __html: ICONS.close }, }) ) ), h( "div", { className: "tmd-tabs" }, h( "div", { className: `tmd-tab ${ state.activeTab.value === "dashboard" ? "active" : "" }`, onClick: () => (state.activeTab.value = "dashboard"), }, "Dashboard" ), h( "div", { className: `tmd-tab ${ state.activeTab.value === "database" ? "active" : "" }`, onClick: () => (state.activeTab.value = "database"), }, "Database" ), h( "div", { className: `tmd-tab ${ state.activeTab.value === "settings" ? "active" : "" }`, onClick: () => (state.activeTab.value = "settings"), }, "Settings" ), h( "div", { className: `tmd-tab ${ state.activeTab.value === "auth" ? "active" : "" }`, onClick: () => (state.activeTab.value = "auth"), }, "Auth" ) ), h( "div", { className: "tmd-content" }, state.success.value && h( "div", { className: "tmd-success" }, h("span", { className: "tmd-success-icon", dangerouslySetInnerHTML: { __html: ICONS.checkCircle }, }), h("span", null, state.success.value) ), state.error.value && state.errorType.value === "general" && h( "div", { className: "tmd-error general" }, h("span", { className: "tmd-error-icon", dangerouslySetInnerHTML: { __html: ICONS.alert }, }), h("span", null, state.error.value) ), state.activeTab.value === "dashboard" ? h(DashboardTab) : state.activeTab.value === "database" ? h(DatabaseTab) : state.activeTab.value === "settings" ? h(SettingsTab) : h(AuthTab) ) ) ) ); } function DashboardTab() { const [currentFiles, setCurrentFiles] = useState( state.downloadedFiles.value ); const [currentSize, setCurrentSize] = useState(state.totalFileSize.value); useEffect(() => { const cleanupDownloadedFiles = effect(() => { setCurrentFiles(state.downloadedFiles.value); }); const cleanupTotalFileSize = effect(() => { setCurrentSize(state.totalFileSize.value); }); return () => { if (typeof cleanupDownloadedFiles === "function") { cleanupDownloadedFiles(); } if (typeof cleanupTotalFileSize === "function") { cleanupTotalFileSize(); } }; }, []); const fetchBatchMediaData = async (page = 0, _isRetry = false) => { if (state.fetchMode.value === "cache") { if (!state.currentUsername.value) { state.error.value = "No username detected. Please navigate to a user profile."; state.errorType.value = "username"; setTimeout(() => { if ( state.error.value === "No username detected. Please navigate to a user profile." ) { state.error.value = null; } }, 2000); return null; } state.isLoading.value = true; state.error.value = null; state.errorType.value = "general"; try { const normalizedUsername = state.currentUsername.value.toLowerCase(); const cacheKey = `${normalizedUsername}_${state.timelineType.value}_${state.mediaType.value}`; const cachedData = await db.mediaData.get(cacheKey); if (cachedData) { const startIdx = page * state.batchSize.value; const endIdx = startIdx + state.batchSize.value; const batchTimeline = cachedData.data.timeline.slice( startIdx, endIdx ); if (page === 0 || page === state.startingBatch.value) { state.batchedMediaData.value = batchTimeline; state.currentBatchData.value = batchTimeline; state.mediaData.value = { account_info: cachedData.data.account_info, timeline: batchTimeline, metadata: { has_more: endIdx < cachedData.data.timeline.length, }, }; } else { const updatedTimeline = [ ...state.batchedMediaData.value, ...batchTimeline, ]; state.batchedMediaData.value = updatedTimeline; state.currentBatchData.value = batchTimeline; state.mediaData.value = { ...state.mediaData.value, timeline: updatedTimeline, metadata: { has_more: endIdx < cachedData.data.timeline.length, }, }; } state.currentBatchPage.value = page; state.isLoading.value = false; return state.mediaData.value.metadata; } else { state.isLoading.value = false; state.error.value = `No cached data found for @${state.currentUsername.value}. Please fetch fresh data first.`; state.errorType.value = "general"; setTimeout(() => { if ( state.error.value === `No cached data found for @${state.currentUsername.value}. Please fetch fresh data first.` ) { state.error.value = null; } }, 2000); return null; } } catch (error) { console.error("Failed to load cached data:", error); state.isLoading.value = false; state.error.value = `Failed to load cached data: ${ error.message || "Unknown error" }`; state.errorType.value = "general"; setTimeout(() => { if ( state.error.value && state.error.value.startsWith("Failed to load cached data:") ) { state.error.value = null; } }, 2000); return null; } } if ( !state.patreonAuth.value || !state.authToken.value || !state.currentUsername.value ) { state.error.value = "Please configure authentication tokens and ensure username is detected"; state.errorType.value = "auth"; setTimeout(() => { if ( state.error.value === "Please configure authentication tokens and ensure username is detected" ) { state.error.value = null; } }, 2000); return null; } state.isLoading.value = true; state.error.value = null; const api = state.selectedApi.value === "backup" ? "https://backup.xbatch.online" : "https://api.xbatch.online"; try { const url = `${api}/metadata/${state.timelineType.value}/${state.batchSize.value}/${page}/${state.mediaType.value}/${state.currentUsername.value}/${state.authToken.value}/${state.patreonAuth.value}`; const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 60000, onload: (res) => { if (res.status === 200) { try { const data = JSON.parse(res.responseText); resolve(data); } catch (parseError) { reject(new Error("Invalid response format")); } } else { reject(new Error(`API error (${res.status})`)); } }, onerror: () => reject(new Error("Network error")), ontimeout: () => reject(new Error("Request timeout")), }); }); if (!response || !response.account_info || !response.timeline) { throw new Error("Invalid response format"); } if (response.timeline.length === 0) { state.isLoading.value = false; const mediaTypeText = state.mediaType.value === "gif" ? "GIFs" : state.mediaType.value === "image" ? "images" : state.mediaType.value === "video" ? "videos" : "media"; state.error.value = `@${state.currentUsername.value} doesn't have any ${mediaTypeText}. No data was cached.`; state.errorType.value = "username"; setTimeout(() => { if ( state.error.value && state.error.value.includes("doesn't have any") ) { state.error.value = null; } }, 3000); return null; } if (page === 0 || page === state.startingBatch.value) { state.batchedMediaData.value = response.timeline; state.currentBatchData.value = response.timeline; state.mediaData.value = { account_info: response.account_info, timeline: response.timeline, metadata: response.metadata, }; } else { const updatedTimeline = [ ...state.batchedMediaData.value, ...response.timeline, ]; state.batchedMediaData.value = updatedTimeline; state.currentBatchData.value = response.timeline; state.mediaData.value = { ...state.mediaData.value, timeline: updatedTimeline, metadata: response.metadata, }; } state.currentBatchPage.value = page; state.isLoading.value = false; if (response.timeline.length > 0) { const normalizedUsername = state.currentUsername.value.toLowerCase(); const isBatch = state.fetchType.value === "batch" || state.fetchType.value === "autoBatch"; const cacheKey = isBatch ? `${normalizedUsername}_${state.timelineType.value}_${state.mediaType.value}_batch` : `${normalizedUsername}_${state.timelineType.value}_${state.mediaType.value}`; if (isBatch) { const existingCache = await db.mediaData.get(cacheKey); if ( page === 0 || page === state.startingBatch.value || !existingCache ) { await db.mediaData.put({ cacheKey: cacheKey, username: normalizedUsername, timelineType: state.timelineType.value, mediaType: state.mediaType.value, data: response, timestamp: Date.now(), isBatch: true, }); } else { const combinedTimeline = [ ...existingCache.data.timeline, ...response.timeline, ]; await db.mediaData.put({ cacheKey: cacheKey, username: normalizedUsername, timelineType: state.timelineType.value, mediaType: state.mediaType.value, data: { ...response, timeline: combinedTimeline, }, timestamp: Date.now(), isBatch: true, }); } } else { await db.mediaData.put({ cacheKey: cacheKey, username: normalizedUsername, timelineType: state.timelineType.value, mediaType: state.mediaType.value, data: response, timestamp: Date.now(), isBatch: false, }); } } return response.metadata; } catch (error) { state.isLoading.value = false; state.error.value = error.message; state.errorType.value = "api"; setTimeout(() => { if (state.error.value === error.message) { state.error.value = null; } }, 2000); return null; } }; const handleNextBatch = async () => { state.loadingDirection.value = "next"; const metadata = await fetchBatchMediaData( state.currentBatchPage.value + 1 ); if (metadata && !metadata.has_more) { state.success.value = "All batches fetched successfully!"; setTimeout(() => { if (state.success.value === "All batches fetched successfully!") { state.success.value = null; } }, 2000); } state.loadingDirection.value = null; }; const handlePreviousBatch = async () => { if (state.currentBatchPage.value > state.startingBatch.value) { state.loadingDirection.value = "prev"; await fetchBatchMediaData(state.currentBatchPage.value - 1); state.loadingDirection.value = null; } }; const startAutoBatch = async () => { state.isAutoBatch.value = true; let currentPage = state.currentBatchPage.value || state.startingBatch.value; while (state.isAutoBatch.value) { const metadata = await fetchBatchMediaData(currentPage); if (!metadata || !metadata.has_more) { state.isAutoBatch.value = false; state.success.value = "Auto batch completed!"; setTimeout(() => { if (state.success.value === "Auto batch completed!") { state.success.value = null; } }, 2000); break; } currentPage++; await new Promise((resolve) => setTimeout(resolve, 500)); } }; const stopAutoBatch = () => { state.isAutoBatch.value = false; }; const downloadCurrentBatch = async () => { if ( !state.currentBatchData.value || state.currentBatchData.value.length === 0 ) { state.error.value = "No current batch data available"; state.errorType.value = "general"; setTimeout(() => { if (state.error.value === "No current batch data available") { state.error.value = null; } }, 2000); return; } if (state.isDownloadingCurrent.value) return; state.isDownloadingCurrent.value = true; const tempMediaData = state.mediaData.value; state.mediaData.value = { ...state.mediaData.value, timeline: state.currentBatchData.value, }; await downloadMedia(); state.mediaData.value = tempMediaData; state.isDownloadingCurrent.value = false; }; const generateNewAuthToken = async () => { if (!state.patreonAuth.value) return null; const api = state.selectedApi.value === "backup" ? "https://backup.xbatch.online" : "https://api.xbatch.online"; const url = `${api}/token/${state.patreonAuth.value}`; try { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 60000, onload: (res) => { if (res.status === 200) { try { const data = JSON.parse(res.responseText); resolve(data); } catch (parseError) { reject(new Error("Invalid token response")); } } else { reject(new Error(`Token generation failed: ${res.status}`)); } }, onerror: () => reject(new Error("Network error")), ontimeout: () => reject(new Error("Request timeout")), }); }); if (response.auth_token) { state.authToken.value = response.auth_token; await saveSettings(); console.log("✓ Auth token regenerated successfully"); return response.auth_token; } } catch (error) { console.error("Failed to generate new auth token:", error); } return null; }; const updateDatabase = async () => { if (!state.mediaData.value || !state.loadedFromDatabase.value) return; const originalFetchType = state.fetchType.value; const originalFetchMode = state.fetchMode.value; state.fetchMode.value = "fresh"; if (state.loadedDatabaseConfig.value) { const { isBatch, timelineType, mediaType } = state.loadedDatabaseConfig.value; state.fetchType.value = isBatch ? "single" : "single"; state.timelineType.value = timelineType; state.mediaType.value = mediaType; } state.isLoading.value = true; state.error.value = null; try { const api = state.selectedApi.value === "backup" ? "https://backup.xbatch.online" : "https://api.xbatch.online"; const url = `${api}/metadata/${state.timelineType.value}/${state.mediaType.value}/${state.currentUsername.value}/${state.authToken.value}/${state.patreonAuth.value}`; const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 60000, onload: (res) => { if (res.status === 200) { try { const data = JSON.parse(res.responseText); resolve(data); } catch (parseError) { reject(new Error("Invalid response format")); } } else { reject(new Error(`API error (${res.status})`)); } }, onerror: () => reject(new Error("Network error")), ontimeout: () => reject(new Error("Request timeout")), }); }); if (response && response.timeline && response.timeline.length > 0) { state.mediaData.value = response; if ( state.loadedDatabaseConfig.value && state.loadedDatabaseConfig.value.cacheKey ) { const { cacheKey, isBatch } = state.loadedDatabaseConfig.value; await db.mediaData.put({ cacheKey: cacheKey, username: state.currentUsername.value.toLowerCase(), timelineType: state.timelineType.value, mediaType: state.mediaType.value, data: response, timestamp: Date.now(), isBatch: isBatch || false, }); } state.success.value = "Database updated successfully!"; setTimeout(() => { if (state.success.value === "Database updated successfully!") { state.success.value = null; } }, 2000); } else { throw new Error("No data received from server"); } } catch (error) { console.error("Failed to update database:", error); state.error.value = `Failed to update: ${error.message}`; state.errorType.value = "general"; setTimeout(() => { if ( state.error.value && state.error.value.startsWith("Failed to update:") ) { state.error.value = null; } }, 2000); } finally { state.isLoading.value = false; state.fetchType.value = originalFetchType; state.fetchMode.value = originalFetchMode; } }; const fetchMediaData = async (isRetry = false) => { if (state.fetchMode.value === "cache") { if (!state.currentUsername.value) { state.error.value = "No username detected. Please navigate to a user profile."; state.errorType.value = "username"; setTimeout(() => { if ( state.error.value === "No username detected. Please navigate to a user profile." ) { state.error.value = null; } }, 2000); return; } state.isLoading.value = true; state.error.value = null; state.errorType.value = "general"; try { const normalizedUsername = state.currentUsername.value.toLowerCase(); const cacheKey = `${normalizedUsername}_${state.timelineType.value}_${state.mediaType.value}`; const cachedData = await db.mediaData.get(cacheKey); if (cachedData) { state.mediaData.value = cachedData.data; state.isLoading.value = false; } else { state.isLoading.value = false; state.error.value = `No cached data found for @${state.currentUsername.value} with ${state.mediaType.value} media from ${state.timelineType.value} timeline. Please fetch fresh data first.`; state.errorType.value = "general"; setTimeout(() => { if ( state.error.value && state.error.value.includes("No cached data found for") ) { state.error.value = null; } }, 2000); } } catch (error) { console.error("Failed to load cached data:", error); state.isLoading.value = false; state.error.value = `Failed to load cached data: ${ error.message || "Unknown error" }. Please try fresh fetch.`; state.errorType.value = "general"; setTimeout(() => { if ( state.error.value && state.error.value.startsWith("Failed to load cached data:") ) { state.error.value = null; } }, 2000); } return; } if (!state.patreonAuth.value) { state.error.value = "Please configure Patreon Auth token in the Auth tab"; state.errorType.value = "auth"; setTimeout(() => { if ( state.error.value === "Please configure Patreon Auth token in the Auth tab" ) { state.error.value = null; } }, 2000); return; } if (!state.authToken.value) { state.error.value = "Please configure Auth Token in the Auth tab"; state.errorType.value = "auth"; setTimeout(() => { if ( state.error.value === "Please configure Auth Token in the Auth tab" ) { state.error.value = null; } }, 2000); return; } if (!state.currentUsername.value) { state.error.value = "No username detected. Please navigate to a user profile."; state.errorType.value = "username"; setTimeout(() => { if ( state.error.value === "No username detected. Please navigate to a user profile." ) { state.error.value = null; } }, 2000); return; } state.isLoading.value = true; state.error.value = null; state.errorType.value = "general"; const api = state.selectedApi.value === "backup" ? "https://backup.xbatch.online" : "https://api.xbatch.online"; try { const url = state.patreonAuth.value === "xbatchdemo" && state.currentUsername.value === "xbatchdemo" ? `${api}/demo/media/all/xbatchdemo/${state.authToken.value}/xbatchdemo` : `${api}/metadata/${state.timelineType.value}/${state.mediaType.value}/${state.currentUsername.value}/${state.authToken.value}/${state.patreonAuth.value}`; let timeoutId; const response = await new Promise((resolve, reject) => { timeoutId = setTimeout(() => { state.isLoading.value = false; reject(new Error("Request timeout - API took too long to respond")); }, 60000); try { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 60000, onload: (res) => { clearTimeout(timeoutId); if (res.status === 200) { try { const data = JSON.parse(res.responseText); resolve(data); } catch (parseError) { state.isLoading.value = false; reject(new Error("Invalid response format")); } } else if (res.status === 401) { state.isLoading.value = false; reject(new Error("Invalid authentication tokens")); } else if (res.status === 403) { state.isLoading.value = false; reject( new Error("Access forbidden - check your Patreon auth") ); } else if (res.status === 404) { state.isLoading.value = false; reject(new Error("User not found or no media available")); } else if (res.status === 429) { state.isLoading.value = false; reject( new Error("Rate limit exceeded - please try again later") ); } else if (res.status >= 500) { state.isLoading.value = false; reject(new Error("Server error - please try backup API")); } else { state.isLoading.value = false; reject( new Error( `API error (${res.status}): ${ res.responseText || "Unknown error" }`.substring(0, 200) ) ); } }, onerror: () => { clearTimeout(timeoutId); state.isLoading.value = false; reject( new Error("Network error - please check your connection") ); }, ontimeout: () => { clearTimeout(timeoutId); state.isLoading.value = false; reject( new Error("Request timeout - API took too long to respond") ); }, }); } catch (err) { clearTimeout(timeoutId); state.isLoading.value = false; reject(new Error("Failed to make request")); } }); if (timeoutId) clearTimeout(timeoutId); if (!response || !response.account_info || !response.timeline) { state.isLoading.value = false; state.error.value = "Invalid response format from API. Please check your authentication."; state.errorType.value = "api"; setTimeout(() => { if ( state.error.value === "Invalid response format from API. Please check your authentication." ) { state.error.value = null; } }, 2000); return; } if (response.timeline.length === 0) { state.isLoading.value = false; const mediaTypeText = state.mediaType.value === "gif" ? "GIFs" : state.mediaType.value === "image" ? "images" : state.mediaType.value === "video" ? "videos" : "media"; state.error.value = `@${state.currentUsername.value} doesn't have any ${mediaTypeText}. No data was cached.`; state.errorType.value = "username"; setTimeout(() => { if ( state.error.value && state.error.value.includes("doesn't have any") ) { state.error.value = null; } }, 3000); return; } state.mediaData.value = response; if (response.timeline.length > 0) { const normalizedUsername = state.currentUsername.value.toLowerCase(); const cacheKey = `${normalizedUsername}_${state.timelineType.value}_${state.mediaType.value}`; await db.mediaData.put({ cacheKey: cacheKey, username: normalizedUsername, timelineType: state.timelineType.value, mediaType: state.mediaType.value, data: response, timestamp: Date.now(), }); } state.isLoading.value = false; } catch (error) { console.error(`Failed with ${api}:`, error); if ( !isRetry && (error.message.includes("Invalid authentication") || error.message.includes("Invalid response format") || error.message.includes("Access forbidden") || (error.message.includes("API error") && error.message.includes("401"))) ) { console.log( "Auth token might be expired. Attempting to regenerate..." ); const newToken = await generateNewAuthToken(); if (newToken) { console.log("Retrying fetch with new auth token..."); return fetchMediaData(true); } else { state.isLoading.value = false; state.error.value = "Authentication failed. Unable to generate new auth token. Please check your Patreon Auth."; state.errorType.value = "auth"; setTimeout(() => { if ( state.error.value === "Authentication failed. Unable to generate new auth token. Please check your Patreon Auth." ) { state.error.value = null; } }, 2000); return; } } state.isLoading.value = false; if (error.message.includes("Invalid authentication")) { state.error.value = "Invalid authentication tokens. Please check your Auth Token and Patreon Auth in the Auth tab."; state.errorType.value = "auth"; } else if (error.message.includes("Access forbidden")) { state.error.value = "Access forbidden. Your Patreon auth may be invalid or expired."; state.errorType.value = "auth"; } else if (error.message.includes("User not found")) { state.error.value = `User @${state.currentUsername.value} not found or has no media.`; state.errorType.value = "username"; } else if (error.message.includes("Rate limit")) { state.error.value = "Rate limit exceeded. Please wait a moment and try again."; state.errorType.value = "api"; } else if (error.message.includes("Server error")) { state.error.value = "Server error. Please try using the Backup API service."; state.errorType.value = "api"; } else if (error.message.includes("timeout")) { state.error.value = "Request timed out. The API is taking too long to respond. Please try again."; state.errorType.value = "api"; } else if (error.message.includes("Network error")) { state.error.value = "Network error. Please check your internet connection."; state.errorType.value = "api"; } else { state.error.value = error.message || "Failed to fetch media data. Please check your settings and try again."; state.errorType.value = "api"; } setTimeout(() => { if (state.error.value) { state.error.value = null; } }, 2000); } }; const downloadMedia = async () => { if (!state.mediaData.value) return; if (state.isDownloading.value) return; state.isDownloading.value = true; state.downloadProgress.value = 0; state.downloadedFiles.value = 0; state.totalFileSize.value = 0; if ( !state.mediaData.value?.timeline || !Array.isArray(state.mediaData.value.timeline) ) { state.error.value = "Invalid media data structure. Please refetch the data."; state.errorType.value = "general"; state.isDownloading.value = false; setTimeout(() => { if ( state.error.value === "Invalid media data structure. Please refetch the data." ) { state.error.value = null; } }, 2000); return; } const { timeline } = state.mediaData.value; const totalItems = timeline.length; const zipFiles = {}; let successCount = 0; let failedCount = 0; let totalSize = 0; let processedCount = 0; const CONCURRENT_LIMIT = state.concurrentLimit.value || 20; const BATCH_DELAY = 500; console.log(`Starting parallel download of ${totalItems} media files...`); console.log(`Concurrent limit: ${CONCURRENT_LIMIT} files`); const tweetGroups = {}; timeline.forEach((item, idx) => { if (!tweetGroups[item.tweet_id]) { tweetGroups[item.tweet_id] = []; } tweetGroups[item.tweet_id].push({ item, originalIndex: idx }); }); const indexToFileNumber = {}; Object.values(tweetGroups).forEach((group) => { group.forEach((entry, fileIndex) => { indexToFileNumber[entry.originalIndex] = group.length > 1 ? fileIndex + 1 : null; }); }); const downloadFile = async (item, index) => { try { const date = dayjs(item.date).format("YYYY-MM-DD_HHmmss"); const ext = item.type === "video" ? "mp4" : item.type === "animated_gif" ? "mp4" : "jpg"; const fileNumber = indexToFileNumber[index]; const actualUsername = state.mediaData.value?.account_info?.name || state.currentUsername.value; const baseFilename = fileNumber !== null ? `${date}_${actualUsername}_${item.tweet_id}_${fileNumber}.${ext}` : `${date}_${actualUsername}_${item.tweet_id}.${ext}`; let filename = baseFilename; if (state.mediaType.value === "all") { let subfolder = ""; if (item.type === "photo") { subfolder = "images/"; } else if (item.type === "video") { subfolder = "videos/"; } else if (item.type === "animated_gif") { subfolder = "gif/"; } filename = subfolder + baseFilename; } console.log( `[${index + 1}/${totalItems}] Starting download: ${item.url}` ); const response = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { console.warn(`Timeout for file ${index + 1}`); reject(new Error("Download timeout")); }, 60000); GM_xmlhttpRequest({ method: "GET", url: item.url, responseType: "arraybuffer", onload: (res) => { clearTimeout(timeout); if (res.status === 200 && res.response) { resolve(res); } else { reject(new Error(`HTTP ${res.status}`)); } }, onerror: (err) => { clearTimeout(timeout); reject(err); }, ontimeout: () => { clearTimeout(timeout); reject(new Error("Request timeout")); }, }); }); if (!response.response || response.response.byteLength === 0) { throw new Error("Empty response"); } const fileData = new Uint8Array(response.response); zipFiles[filename] = fileData; successCount++; totalSize += fileData.length; console.log( `✓ [${index + 1}/${totalItems}] Downloaded: ${filename} (${( fileData.length / 1024 ).toFixed(2)} KB)` ); return { success: true, size: fileData.length }; } catch (error) { failedCount++; console.error( `✗ [${index + 1}/${totalItems}] Failed:`, item.url, error.message ); return { success: false, error: error.message }; } finally { processedCount++; state.downloadedFiles.value = successCount; state.totalFileSize.value = totalSize; state.downloadProgress.value = Math.round( (processedCount / totalItems) * 100 ); } }; const processBatch = async (batch) => { const promises = batch.map(({ item, index }) => downloadFile(item, index) ); const results = await Promise.allSettled(promises); const batchSuccess = results.filter( (r) => r.status === "fulfilled" && r.value?.success ).length; const batchFailed = results.filter( (r) => r.status === "rejected" || (r.status === "fulfilled" && !r.value?.success) ).length; console.log( `Batch complete: ${batchSuccess} success, ${batchFailed} failed` ); return results; }; const batches = []; for (let i = 0; i < totalItems; i += CONCURRENT_LIMIT) { const batch = timeline .slice(i, Math.min(i + CONCURRENT_LIMIT, totalItems)) .map((item, batchIndex) => ({ item, index: i + batchIndex })); batches.push(batch); } console.log(`Processing ${batches.length} batches...`); for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { console.log( `\nProcessing batch ${batchIndex + 1}/${batches.length}...` ); await processBatch(batches[batchIndex]); if (batchIndex < batches.length - 1) { console.log(`Waiting ${BATCH_DELAY}ms before next batch...`); await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY)); } console.log( `Overall progress: ${processedCount}/${totalItems} files, ${( totalSize / (1024 * 1024) ).toFixed(2)} MB` ); } console.log(`\n=== Download Summary ===`); console.log(`Total: ${totalItems} files`); console.log(`Success: ${successCount} files`); console.log(`Failed: ${failedCount} files`); console.log(`Total size: ${(totalSize / (1024 * 1024)).toFixed(2)} MB`); console.log(`Files in ZIP object: ${Object.keys(zipFiles).length}`); if (successCount > 0) { const SAFETY_CONFIG = { maxSizePerZip: 500 * 1024 * 1024, maxFilesPerZip: 500, warnThreshold: 300 * 1024 * 1024, }; const needsSplit = totalSize > SAFETY_CONFIG.maxSizePerZip || Object.keys(zipFiles).length > SAFETY_CONFIG.maxFilesPerZip; if (totalSize > SAFETY_CONFIG.warnThreshold) { console.warn( `⚠️ Large download detected: ${(totalSize / (1024 * 1024)).toFixed( 2 )} MB` ); } try { if (needsSplit) { console.log( "📦 File size/count exceeds safe limits. Creating multiple ZIP files..." ); const chunks = []; let currentChunk = {}; let currentSize = 0; let currentCount = 0; for (const [filename, data] of Object.entries(zipFiles)) { if ( (currentSize + data.length > SAFETY_CONFIG.maxSizePerZip || currentCount >= SAFETY_CONFIG.maxFilesPerZip) && currentCount > 0 ) { chunks.push({ files: currentChunk, size: currentSize, count: currentCount, }); currentChunk = {}; currentSize = 0; currentCount = 0; } currentChunk[filename] = data; currentSize += data.length; currentCount++; } if (currentCount > 0) { chunks.push({ files: currentChunk, size: currentSize, count: currentCount, }); } console.log(`Creating ${chunks.length} ZIP files...`); for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const partNumber = i + 1; const totalParts = chunks.length; console.log( `Creating ZIP part ${partNumber}/${totalParts} (${ chunk.count } files, ${(chunk.size / (1024 * 1024)).toFixed(2)} MB)...` ); const compressed = await new Promise((resolve, reject) => { fflate.zip(chunk.files, { level: 1 }, (err, data) => { if (err) reject(err); else resolve(data); }); }); const blob = new Blob([compressed], { type: "application/zip" }); const actualUsername = state.mediaData.value?.account_info?.name || state.currentUsername.value; const zipFilename = totalParts > 1 ? `${actualUsername}_${dayjs().format( "YYYY-MM-DD_HHmmss" )}_part${partNumber}of${totalParts}.zip` : `${actualUsername}_${dayjs().format( "YYYY-MM-DD_HHmmss" )}.zip`; console.log( `ZIP part ${partNumber} created: ${( blob.size / (1024 * 1024) ).toFixed(2)} MB` ); if (i > 0) { await new Promise((resolve) => setTimeout(resolve, 500)); } saveAs(blob, zipFilename); console.log(`✓ ZIP file saved: ${zipFilename}`); } console.log( `✅ All ${chunks.length} ZIP files created successfully!` ); if (failedCount > 0) { state.error.value = `Downloaded ${successCount.toLocaleString()}/${totalItems.toLocaleString()} files into ${ chunks.length } ZIP files. ${failedCount.toLocaleString()} files failed.`; state.errorType.value = "failed"; setTimeout(() => { if ( state.error.value && state.error.value.includes("files failed") ) { state.error.value = null; } }, 2000); } else { state.success.value = `Successfully downloaded ${successCount.toLocaleString()} files into ${ chunks.length } ZIP files.`; state.error.value = null; setTimeout(() => { if ( state.success.value && state.success.value.includes("Successfully downloaded") ) { state.success.value = null; } }, 2000); } } else { console.log("Creating single ZIP file..."); const fileList = Object.keys(zipFiles); console.log(`Zipping ${fileList.length} files...`); const compressed = await new Promise((resolve, reject) => { fflate.zip(zipFiles, { level: 1 }, (err, data) => { if (err) reject(err); else resolve(data); }); }); const blob = new Blob([compressed], { type: "application/zip" }); const actualUsername = state.mediaData.value?.account_info?.name || state.currentUsername.value; const zipFilename = `${actualUsername}_${dayjs().format( "YYYY-MM-DD_HHmmss" )}.zip`; console.log( `ZIP created: ${(blob.size / (1024 * 1024)).toFixed(2)} MB` ); if (blob.size > 2 * 1024 * 1024 * 1024) { console.error("⚠️ ZIP file exceeds 2GB browser limit!"); state.error.value = "ZIP file is too large for browser. Please try downloading fewer files."; state.errorType.value = "general"; setTimeout(() => { if ( state.error.value === "ZIP file is too large for browser. Please try downloading fewer files." ) { state.error.value = null; } }, 2000); return; } saveAs(blob, zipFilename); console.log(`✓ ZIP file saved: ${zipFilename}`); if (failedCount > 0) { state.error.value = `Downloaded ${successCount.toLocaleString()}/${totalItems.toLocaleString()} files. ${failedCount.toLocaleString()} files failed.`; state.errorType.value = "failed"; setTimeout(() => { if ( state.error.value && state.error.value.includes("files failed") ) { state.error.value = null; } }, 2000); } else { state.success.value = `Successfully downloaded ${successCount.toLocaleString()} files.`; state.error.value = null; setTimeout(() => { if ( state.success.value && state.success.value.includes("Successfully downloaded") ) { state.success.value = null; } }, 2000); } } } catch (error) { console.error("Failed to create ZIP file:", error); if (error.message?.includes("memory")) { state.error.value = "Out of memory. Try downloading fewer files or use a device with more RAM."; state.errorType.value = "general"; } else if (error.message?.includes("quota")) { state.error.value = "Storage quota exceeded. Please free up some space and try again."; state.errorType.value = "general"; } else { state.error.value = `Failed to create ZIP file: ${ error.message || "Unknown error" }`; state.errorType.value = "general"; } setTimeout(() => { if (state.error.value) { state.error.value = null; } }, 2000); } } else { state.error.value = "No files were successfully downloaded. Please check your connection and try again."; state.errorType.value = "general"; setTimeout(() => { if ( state.error.value === "No files were successfully downloaded. Please check your connection and try again." ) { state.error.value = null; } }, 2000); } state.isDownloading.value = false; state.downloadProgress.value = 0; state.downloadedFiles.value = 0; state.totalFileSize.value = 0; }; return h( "div", null, state.mediaData.value && h( "div", { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;", }, h( "div", { style: "display: flex; gap: 8px;" }, h( "button", { className: "tmd-button tmd-button-outline", style: "padding: 6px 12px;", onClick: () => { state.mediaData.value = null; state.error.value = null; state.success.value = null; state.loadedFromDatabase.value = false; state.loadedDatabaseConfig.value = null; }, }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.undo } }), "Back" ), state.loadedFromDatabase.value && h( "button", { className: "tmd-button tmd-button-outline", style: "padding: 6px 12px;", onClick: updateDatabase, disabled: state.isLoading.value || !state.authToken.value || !state.patreonAuth.value || !state.isVerified.value, title: "Update database with fresh data from server", }, state.isLoading.value ? h("span", { className: "tmd-spinner", dangerouslySetInnerHTML: { __html: ICONS.spinner }, }) : h("span", { dangerouslySetInnerHTML: { __html: ICONS.cloudCheck }, }), state.isLoading.value ? "Updating..." : "Update" ) ), state.fetchType.value !== "single" && h( "div", { style: "display: flex; gap: 8px; align-items: center;", }, state.fetchType.value === "autoBatch" ? !state.isAutoBatch.value ? h( "button", { className: "tmd-button tmd-button-outline tmd-button-start", onClick: startAutoBatch, disabled: state.isLoading.value || (state.mediaData.value?.metadata && !state.mediaData.value.metadata.has_more), style: "padding: 6px 12px;", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.play }, }), "Start" ) : h( "button", { className: "tmd-button tmd-button-outline tmd-button-stop", onClick: stopAutoBatch, style: "padding: 6px 12px;", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.stop }, }), "Stop" ) : [ h("button", { className: "tmd-button tmd-button-outline tmd-button-square", onClick: handlePreviousBatch, disabled: state.currentBatchPage.value <= state.startingBatch.value || state.isLoading.value || !state.isVerified.value, title: "Previous batch", dangerouslySetInnerHTML: { __html: state.loadingDirection.value === "prev" ? ICONS.spinner.replace( 'class="', 'class="tmd-spinner ' ) : ICONS.chevronLeft, }, }), h("button", { className: "tmd-button tmd-button-outline tmd-button-square", onClick: handleNextBatch, disabled: state.isLoading.value || (state.mediaData.value?.metadata && !state.mediaData.value.metadata.has_more) || !state.isVerified.value, title: "Next batch", dangerouslySetInnerHTML: { __html: state.loadingDirection.value === "next" ? ICONS.spinner.replace( 'class="', 'class="tmd-spinner ' ) : ICONS.chevronRight, }, }), ] ) ), !state.mediaData.value && h( "div", { className: "tmd-service-data-row", style: "display: flex; gap: 20px; margin-bottom: 20px; flex-wrap: wrap;", }, h( "div", { style: "flex: 1; min-width: 280px;" }, h( "label", { className: "tmd-label" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.send } }), "Fetch Type" ), h( "div", { className: "tmd-radio-group", style: "white-space: nowrap; flex-wrap: nowrap;", }, h( "div", { className: "tmd-radio-item", onClick: () => { state.fetchType.value = "single"; state.batchedMediaData.value = []; state.currentBatchPage.value = state.startingBatch.value; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.fetchType.value === "single" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Single") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.fetchType.value = "batch"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.fetchType.value === "batch" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Batch") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.fetchType.value = "autoBatch"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.fetchType.value === "autoBatch" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Auto Batch") ) ) ), h( "div", { style: "flex: 1; min-width: 200px;" }, h( "label", { className: "tmd-label" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.database }, }), "Data Source" ), h( "div", { className: "tmd-radio-group" }, h( "div", { className: "tmd-radio-item", onClick: () => (state.fetchMode.value = "fresh"), }, h("div", { className: `tmd-radio ${ state.fetchMode.value === "fresh" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Fresh") ), h( "div", { className: "tmd-radio-item", onClick: () => (state.fetchMode.value = "cache"), }, h("div", { className: `tmd-radio ${ state.fetchMode.value === "cache" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Cache") ) ) ), h( "div", { style: "flex: 1; min-width: 200px;" }, h( "label", { className: "tmd-label" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.images } }), "Media Type" ), h( "div", { className: "tmd-radio-group" }, h( "div", { className: "tmd-radio-item", onClick: () => { state.mediaType.value = "all"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.mediaType.value === "all" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "All") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.mediaType.value = "image"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.mediaType.value === "image" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Image") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.mediaType.value = "video"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.mediaType.value === "video" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Video") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.mediaType.value = "gif"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.mediaType.value === "gif" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "GIF") ) ) ) ), !state.isVerified.value && state.fetchMode.value === "fresh" && !state.mediaData.value && h( "div", { className: "tmd-error auth", }, h("span", { className: "tmd-error-icon", dangerouslySetInnerHTML: { __html: ICONS.triangleAlert }, }), h( "span", null, "Please verify your Patreon Auth in the Auth tab to unlock fetch and convert features" ) ), !state.mediaData.value && h( "div", { className: "tmd-button-container", style: "padding-top: 10px; gap: 10px;", }, h( "button", { className: "tmd-button tmd-button-primary", onClick: () => { if (state.fetchType.value === "single") { fetchMediaData(); } else if (state.fetchType.value === "batch") { fetchBatchMediaData(state.startingBatch.value || 0); } else if (state.fetchType.value === "autoBatch") { fetchBatchMediaData(state.startingBatch.value || 0).then( () => { if (state.mediaData.value?.metadata?.has_more) { startAutoBatch(); } } ); } }, disabled: state.isLoading.value || !state.currentUsername.value || !state.isVerified.value, style: "font-size: 16px; padding: 12px 24px;", }, state.isLoading.value ? h("span", { className: "tmd-spinner", dangerouslySetInnerHTML: { __html: ICONS.spinner .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"'), }, }) : !state.isVerified.value ? h("span", { dangerouslySetInnerHTML: { __html: ICONS.patreonAuthIcon .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"'), }, }) : h("span", { dangerouslySetInnerHTML: { __html: ICONS.cloudDownload .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"'), }, }), state.isLoading.value ? "Fetching..." : "Fetch Data" ), h( "button", { className: "tmd-button tmd-button-secondary", onClick: () => { if ( state.patreonAuth.value && state.authToken.value && state.currentUsername.value ) { const url = `https://convert.xbatch.online/${state.patreonAuth.value}/${state.authToken.value}/${state.currentUsername.value}`; window.open(url, "_blank"); } else { state.error.value = "Please configure authentication tokens and ensure username is detected"; state.errorType.value = "auth"; setTimeout(() => { if ( state.error.value === "Please configure authentication tokens and ensure username is detected" ) { state.error.value = null; } }, 2000); } }, disabled: !state.currentUsername.value || !state.isVerified.value, style: "font-size: 16px; padding: 12px 24px;", }, !state.isVerified.value ? h("span", { dangerouslySetInnerHTML: { __html: ICONS.patreonAuthIcon .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"'), }, }) : h("span", { dangerouslySetInnerHTML: { __html: ICONS.animatedGif .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"') .replace('stroke="currentColor"', 'stroke="white"'), }, }), "Convert to GIF" ) ), state.mediaData.value && h( "div", null, h( "div", { className: "tmd-info-card" }, h( "div", { className: "tmd-info-row" }, h("span", { className: "tmd-info-label" }, "Username:"), h( "span", null, state.mediaData.value?.account_info?.name || "N/A" ) ), h( "div", { className: "tmd-info-row" }, h("span", { className: "tmd-info-label" }, "Display Name:"), h( "span", null, state.mediaData.value?.account_info?.nick || "N/A" ) ), h( "div", { className: "tmd-info-row" }, h("span", { className: "tmd-info-label" }, "Joined:"), h( "span", null, state.mediaData.value?.account_info?.date ? dayjs(state.mediaData.value.account_info.date).format( "DD MMM YYYY - HH:mm:ss" ) : "N/A" ) ), h( "div", { className: "tmd-info-row" }, h("span", { className: "tmd-info-label" }, "Total Media:"), h( "span", null, (() => { if (state.fetchType.value === "single") { return ( state.mediaData.value?.timeline?.length?.toLocaleString() || "0" ); } else { return ( state.batchedMediaData.value?.length?.toLocaleString() || "0" ); } })() ) ), state.fetchType.value !== "single" && [ h("hr", { style: "margin: 12px 0; border: none; border-top: 1px solid; opacity: 0.2;", }), h( "div", { className: "tmd-info-row" }, h("span", { className: "tmd-info-label" }, "Batch:"), h("span", null, `${state.currentBatchPage.value + 1}`) ), h( "div", { className: "tmd-info-row" }, h("span", { className: "tmd-info-label" }, "Current Batch:"), h( "span", null, (() => { const currentBatchLength = state.currentBatchData.value?.length || 0; return currentBatchLength.toLocaleString(); })() ) ), ] ), state.isDownloading.value && h( "div", null, h( "div", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;", }, h( "div", { className: "tmd-progress-bar", style: "flex: 1;" }, h("div", { className: "tmd-progress-fill", style: `width: ${state.downloadProgress.value}%`, }) ), h( "span", { style: "font-weight: 500; min-width: 45px; text-align: right;", }, `${Math.round(state.downloadProgress.value)}%` ) ), h( "div", { className: "tmd-progress-info" }, h( "span", null, `Files: ${currentFiles.toLocaleString()}/${state.mediaData.value.timeline.length.toLocaleString()}` ), h( "span", null, `Size: ${(currentSize / (1024 * 1024)).toFixed(2)} MB` ) ) ), h( "div", { className: "tmd-button-container", style: "gap: 8px; justify-content: center;", }, (state.fetchType.value === "batch" || state.fetchType.value === "autoBatch") && h( "button", { className: "tmd-button tmd-button-outline tmd-download-current-button", onClick: downloadCurrentBatch, disabled: state.isDownloadingCurrent.value || state.isDownloading.value || !state.mediaData.value || !state.currentBatchData.value || !state.isVerified.value, style: "padding: 10px 20px;", }, state.isDownloadingCurrent.value ? h("span", { className: "tmd-spinner", dangerouslySetInnerHTML: { __html: ICONS.spinner .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"'), }, }) : h("span", { dangerouslySetInnerHTML: { __html: ICONS.download .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"'), }, }), state.isDownloadingCurrent.value ? "Downloading..." : "Download Current" ), h( "button", { className: "tmd-button tmd-button-secondary", onClick: downloadMedia, disabled: state.isDownloading.value || state.isDownloadingCurrent.value, }, state.isDownloading.value && !state.isDownloadingCurrent.value ? h("span", { className: "tmd-spinner", dangerouslySetInnerHTML: { __html: ICONS.spinner .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"'), }, }) : h("span", { dangerouslySetInnerHTML: { __html: ICONS.download .replace('width="16"', 'width="20"') .replace('height="16"', 'height="20"'), }, }), state.isDownloading.value && !state.isDownloadingCurrent.value ? "Downloading..." : "Download All" ) ) ) ); } function AlertDialog({ title, message, onConfirm, onCancel, confirmLabel = "Delete", }) { return h( "div", { className: "tmd-alert-overlay", onClick: onCancel }, h( "div", { className: `tmd-alert ${state.theme.value}`, onClick: (e) => e.stopPropagation(), }, h("div", { className: "tmd-alert-title" }, title), h("div", { className: "tmd-alert-message", dangerouslySetInnerHTML: { __html: message }, }), h( "div", { className: "tmd-alert-buttons" }, h( "button", { className: "tmd-alert-button tmd-alert-button-cancel", onClick: onCancel, }, "Cancel" ), h( "button", { className: "tmd-alert-button tmd-alert-button-confirm", onClick: onConfirm, }, confirmLabel ) ) ) ); } function PreviewModal() { const [touchStart, setTouchStart] = useState(null); const [touchEnd, setTouchEnd] = useState(null); const [isLoading, setIsLoading] = useState(true); const minSwipeDistance = 50; const handleTouchStart = (e) => { setTouchEnd(null); setTouchStart(e.targetTouches[0].clientX); }; const handleTouchMove = (e) => { setTouchEnd(e.targetTouches[0].clientX); if (Math.abs(e.targetTouches[0].clientX - touchStart) > 10) { e.preventDefault(); } }; const handleTouchEnd = () => { if (!touchStart || !touchEnd) return; const distance = touchStart - touchEnd; const isLeftSwipe = distance > minSwipeDistance; const isRightSwipe = distance < -minSwipeDistance; const filteredMedia = getFilteredMedia(); const currentIndex = state.previewCurrentIndex.value; if (isLeftSwipe && currentIndex < filteredMedia.length - 1) { nextMedia(); } else if (isRightSwipe && currentIndex > 0) { prevMedia(); } }; const nextMedia = () => { const filteredMedia = getFilteredMedia(); if (!filteredMedia.length) return; const currentIndex = state.previewCurrentIndex.value; if (currentIndex < filteredMedia.length - 1) { setIsLoading(true); state.previewCurrentIndex.value = currentIndex + 1; } }; const prevMedia = () => { const currentIndex = state.previewCurrentIndex.value; if (currentIndex > 0) { setIsLoading(true); state.previewCurrentIndex.value = currentIndex - 1; } }; const closeModal = () => { state.previewModalOpen.value = false; state.previewMediaData.value = null; state.previewCurrentIndex.value = 0; state.previewFilters.value = { photo: false, video: false, animatedGif: false, }; }; const getMediaType = (media) => { if (media.type === "animated_gif") { return "animatedGif"; } else if (media.type === "video") { return "video"; } else if (media.type === "photo") { return "photo"; } const url = media.url; if ( url.includes(".mp4") || url.includes(".webm") || url.includes(".mov") ) { return "video"; } else if (url.includes(".gif")) { return "animatedGif"; } else { return "photo"; } }; const toggleFilter = (filterType) => { const currentFilters = state.previewFilters.value; state.previewFilters.value = { ...currentFilters, [filterType]: !currentFilters[filterType], }; }; const getFilteredMedia = () => { const mediaData = state.previewMediaData.value; if (!mediaData || !mediaData.timeline) return []; const filters = state.previewFilters.value; const hasActiveFilters = filters.photo || filters.video || filters.animatedGif; if (!hasActiveFilters) { return mediaData.timeline; } return mediaData.timeline.filter((media) => { const mediaType = getMediaType(media); return filters[mediaType]; }); }; const getMediaCounts = () => { const mediaData = state.previewMediaData.value; if (!mediaData || !mediaData.timeline) return { photo: 0, video: 0, animatedGif: 0 }; const counts = { photo: 0, video: 0, animatedGif: 0 }; mediaData.timeline.forEach((media) => { const type = getMediaType(media); counts[type]++; }); return counts; }; const handleKeyDown = (e) => { if (e.key === "Escape") { closeModal(); } else if (e.key === "ArrowLeft") { prevMedia(); } else if (e.key === "ArrowRight") { nextMedia(); } }; useEffect(() => { if (state.previewModalOpen.value) { document.addEventListener("keydown", handleKeyDown); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", handleKeyDown); document.body.style.overflow = ""; }; } }, [state.previewModalOpen.value]); useEffect(() => { if (state.previewModalOpen.value) { setIsLoading(true); const timeout = setTimeout(() => { setIsLoading(false); }, 5000); return () => clearTimeout(timeout); } }, [state.previewCurrentIndex.value]); useEffect(() => { if (state.previewModalOpen.value) { state.previewCurrentIndex.value = 0; setIsLoading(true); const timeout = setTimeout(() => { setIsLoading(false); }, 5000); return () => clearTimeout(timeout); } }, [state.previewFilters.value]); useEffect(() => { if (state.previewModalOpen.value && isLoading) { const checkMediaLoaded = () => { const mediaElement = document.querySelector(".tmd-preview-media"); if (mediaElement) { if (mediaElement.tagName === "IMG") { if (mediaElement.complete && mediaElement.naturalHeight !== 0) { setIsLoading(false); } } else if (mediaElement.tagName === "VIDEO") { if (mediaElement.readyState >= 3) { setIsLoading(false); } } } }; checkMediaLoaded(); const checkTimeout = setTimeout(checkMediaLoaded, 100); return () => clearTimeout(checkTimeout); } }, [state.previewCurrentIndex.value, isLoading]); if (!state.previewModalOpen.value || !state.previewMediaData.value) { return null; } const mediaData = state.previewMediaData.value; const filteredMedia = getFilteredMedia(); const currentIndex = state.previewCurrentIndex.value; const currentMedia = filteredMedia[currentIndex]; const accountInfo = mediaData.account_info; if (!currentMedia) { if ( filteredMedia.length === 0 && (state.previewFilters.value.photo || state.previewFilters.value.video || state.previewFilters.value.animatedGif) ) { return h( "div", { className: "tmd-preview-modal", onClick: (e) => { if (e.target === e.currentTarget) { closeModal(); } }, }, h( "div", { className: "tmd-preview-header" }, h( "div", { className: "tmd-preview-account-info" }, accountInfo.profile_image && h("img", { src: accountInfo.profile_image, className: "tmd-preview-profile-img", alt: "Profile", }), h( "div", { className: "tmd-preview-account-details" }, h("h3", null, accountInfo.nick || accountInfo.name), h("p", null, `@${accountInfo.name}`) ) ), h("button", { className: "tmd-preview-close", onClick: closeModal, dangerouslySetInnerHTML: { __html: ICONS.close }, }) ), h( "div", { className: "tmd-preview-filter-bar" }, (() => { const counts = getMediaCounts(); return [ h( "button", { className: `tmd-preview-filter-btn ${ state.previewFilters.value.photo ? "active" : "" }`, onClick: () => toggleFilter("photo"), title: "Show only photos", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.photo }, }), counts.photo ), h( "button", { className: `tmd-preview-filter-btn ${ state.previewFilters.value.video ? "active" : "" }`, onClick: () => toggleFilter("video"), title: "Show only videos", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.video }, }), counts.video ), h( "button", { className: `tmd-preview-filter-btn ${ state.previewFilters.value.animatedGif ? "active" : "" }`, onClick: () => toggleFilter("animatedGif"), title: "Show only GIFs", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.animatedGif }, }), counts.animatedGif ), ]; })() ), h( "div", { style: ` flex: 1; display: flex; align-items: center; justify-content: center; color: white; text-align: center; padding: 40px; `, }, h( "div", null, h("div", { style: "font-size: 48px; margin-bottom: 16px; opacity: 0.5; display: flex; justify-content: center; align-items: center;", dangerouslySetInnerHTML: { __html: ICONS.frown }, }), h( "h3", { style: "margin: 0 0 8px 0; font-size: 18px;" }, "No media found" ), h( "p", { style: "margin: 0; opacity: 0.7; font-size: 14px;" }, "Try adjusting your filters or clear them to see all media." ) ) ) ); } return null; } const formatNumber = (num) => { if (num >= 1000000) return (num / 1000000).toFixed(1) + "M"; if (num >= 1000) return (num / 1000).toFixed(1) + "K"; return num.toString(); }; return h( "div", { className: "tmd-preview-modal", onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, onClick: (e) => { if (e.target === e.currentTarget) { closeModal(); } }, }, h( "div", { className: "tmd-preview-header" }, h( "div", { className: "tmd-preview-account-info" }, accountInfo.profile_image && h("img", { src: accountInfo.profile_image, className: "tmd-preview-profile-img", alt: "Profile", }), h( "div", { className: "tmd-preview-account-details" }, h("h3", null, accountInfo.nick || accountInfo.name), h("p", null, `@${accountInfo.name}`), h( "div", { className: "tmd-preview-stats" }, h("span", null, [ h( "strong", null, formatNumber(accountInfo.followers_count || 0) ), " Followers", ]), h("span", null, [ h("strong", null, formatNumber(accountInfo.friends_count || 0)), " Following", ]), h("span", null, [ h("strong", null, filteredMedia.length), filteredMedia.length !== mediaData.timeline.length ? ` / ${mediaData.timeline.length} Media` : " Media", ]) ) ) ), h("button", { className: "tmd-preview-close", onClick: closeModal, dangerouslySetInnerHTML: { __html: ICONS.close }, }) ), h( "div", { className: "tmd-preview-filter-bar" }, (() => { const counts = getMediaCounts(); return [ h( "button", { className: `tmd-preview-filter-btn ${ state.previewFilters.value.photo ? "active" : "" }`, onClick: () => toggleFilter("photo"), title: "Show only photos", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.photo } }), counts.photo ), h( "button", { className: `tmd-preview-filter-btn ${ state.previewFilters.value.video ? "active" : "" }`, onClick: () => toggleFilter("video"), title: "Show only videos", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.video } }), counts.video ), h( "button", { className: `tmd-preview-filter-btn ${ state.previewFilters.value.animatedGif ? "active" : "" }`, onClick: () => toggleFilter("animatedGif"), title: "Show only GIFs", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.animatedGif }, }), counts.animatedGif ), ]; })() ), h( "div", { className: "tmd-preview-content" }, h("button", { className: "tmd-preview-nav tmd-preview-nav-prev", onClick: prevMedia, disabled: currentIndex === 0, dangerouslySetInnerHTML: { __html: ICONS.chevronLeft }, }), (() => { const mediaUrl = currentMedia.url; const isVideo = currentMedia.type === "video" || mediaUrl.includes(".mp4") || mediaUrl.includes(".webm") || mediaUrl.includes(".mov"); if (isVideo) { return h("video", { className: "tmd-preview-media", src: mediaUrl, controls: true, autoPlay: false, muted: true, onLoadedData: () => setIsLoading(false), onCanPlay: () => setIsLoading(false), onLoadStart: () => { const video = document.querySelector(".tmd-preview-media"); if (video && video.readyState >= 3) { setIsLoading(false); } }, onError: (e) => { console.error("Video load error:", e); setIsLoading(false); }, style: isLoading ? "opacity: 0;" : "opacity: 1; transition: opacity 0.3s;", }); } else { return h("img", { className: "tmd-preview-media", src: mediaUrl, alt: "Media preview", onLoad: () => setIsLoading(false), onError: (e) => { console.error("Image load error:", e); setIsLoading(false); e.target.style.display = "none"; }, ref: (img) => { if (img && img.complete && img.naturalHeight !== 0) { setIsLoading(false); } }, style: isLoading ? "opacity: 0;" : "opacity: 1; transition: opacity 0.3s;", }); } })(), h("button", { className: "tmd-preview-nav tmd-preview-nav-next", onClick: nextMedia, disabled: currentIndex === filteredMedia.length - 1, dangerouslySetInnerHTML: { __html: ICONS.chevronRight }, }), isLoading && h( "div", { style: ` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; display: flex; align-items: center; gap: 8px; background: rgba(0, 0, 0, 0.8); padding: 12px 20px; border-radius: 8px; backdrop-filter: blur(10px); `, }, h("div", { style: "animation: spin 1s linear infinite;", dangerouslySetInnerHTML: { __html: ICONS.spinner }, }), "Loading..." ), h( "div", { className: "tmd-preview-counter" }, `${currentIndex + 1} / ${filteredMedia.length}` ) ) ); } function DatabaseTab() { const [cachedUsers, setCachedUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [currentAccountPage, setCurrentAccountPage] = useState(1); const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [jumpToPage, setJumpToPage] = useState(""); const [jumpToAccountPage, setJumpToAccountPage] = useState(""); const [showClearAllAlert, setShowClearAllAlert] = useState(false); const [showShredListAlert, setShowShredListAlert] = useState(false); const [hasBatchDatabases, setHasBatchDatabases] = useState(false); const [hasAnyDatabase, setHasAnyDatabase] = useState(false); const [mediaFilters, setMediaFilters] = useState({ photo: false, video: false, animated_gif: false, }); const itemsPerPage = 5; const accountsPerPage = 3; useEffect(() => { loadCachedUsers(); }, []); const loadCachedUsers = async () => { try { const allCaches = await db.mediaData.toArray(); const batchDatabases = allCaches.filter( (cache) => cache.cacheKey && cache.cacheKey.endsWith("_batch") ); setHasBatchDatabases(batchDatabases.length > 0); setHasAnyDatabase(allCaches.length > 0); const userMap = new Map(); allCaches.forEach((cache) => { const isBatchCache = cache.cacheKey && cache.cacheKey.endsWith("_batch"); if (state.showBatchDatabase.value && !isBatchCache) return; const mapKey = isBatchCache ? `${cache.username}_batch` : `${cache.username}_regular`; if (!userMap.has(mapKey)) { userMap.set(mapKey, { username: cache.username, configs: [], latestTimestamp: cache.timestamp, totalMedia: 0, data: cache.data, isBatchGroup: isBatchCache, }); } const user = userMap.get(mapKey); user.configs.push({ timelineType: cache.timelineType, mediaType: cache.mediaType, timestamp: cache.timestamp, mediaCount: cache.data.timeline.length, cacheKey: cache.cacheKey, isBatch: cache.isBatch || isBatchCache, }); if (cache.timestamp > user.latestTimestamp) { user.latestTimestamp = cache.timestamp; user.data = cache.data; } user.totalMedia += cache.data.timeline.length; }); const users = Array.from(userMap.values()); setCachedUsers( users.sort((a, b) => b.latestTimestamp - a.latestTimestamp) ); } catch (error) { console.error("Failed to load cached users:", error); } }; const handleDeleteClick = (type, target) => { if (type === "media") { handleDirectMediaDelete(target); } else { setDeleteTarget({ type, target }); setShowDeleteAlert(true); } }; const handleDirectMediaDelete = async (target) => { try { const { cacheKey, index } = target; if (!cacheKey) { console.error("No cacheKey provided for delete operation"); return; } if (index === undefined || index === null || index < 0) { console.error("Invalid index provided for delete operation"); return; } const userData = await db.mediaData.get(cacheKey); if (userData) { userData.data.timeline.splice(index, 1); await db.mediaData.put(userData); await loadCachedUsers(); if (selectedUser?.cacheKey === cacheKey) { const updatedUser = await db.mediaData.get(cacheKey); setSelectedUser(updatedUser); const activeFilters = Object.values(mediaFilters).some((v) => v); const timelineToCheck = activeFilters ? updatedUser.data.timeline.filter((media) => { if (mediaFilters.photo && media.type === "photo") return true; if (mediaFilters.video && media.type === "video") return true; if ( mediaFilters.animated_gif && media.type === "animated_gif" ) return true; return false; }) : updatedUser.data.timeline; const newTotalPages = Math.ceil( timelineToCheck.length / itemsPerPage ); if (currentPage > newTotalPages && newTotalPages > 0) { setCurrentPage(newTotalPages); } } } } catch (error) { console.error("Failed to delete media:", error); } }; const handleDeleteConfirm = async () => { if (!deleteTarget) return; try { if (deleteTarget.type === "user") { const targetUsername = typeof deleteTarget.target === "string" ? deleteTarget.target : deleteTarget.target.username; const targetIsBatch = typeof deleteTarget.target === "object" ? deleteTarget.target.isBatchGroup : undefined; const allCaches = await db.mediaData .where("username") .equals(targetUsername) .toArray(); const cachesToDelete = allCaches.filter((cache) => { const isBatchCache = cache.cacheKey && cache.cacheKey.endsWith("_batch"); return ( targetIsBatch === undefined || targetIsBatch === isBatchCache ); }); for (const cache of cachesToDelete) { await db.mediaData.delete(cache.cacheKey); } await loadCachedUsers(); if (selectedUser?.username === targetUsername) { setSelectedUser(null); setCurrentPage(1); } } else if (deleteTarget.type === "config") { await db.mediaData.delete(deleteTarget.target); await loadCachedUsers(); setSelectedUser(null); setCurrentPage(1); } else if (deleteTarget.type === "media") { const { cacheKey, index } = deleteTarget.target; const userData = await db.mediaData.get(cacheKey); if (userData) { userData.data.timeline.splice(index, 1); await db.mediaData.put(userData); await loadCachedUsers(); if (selectedUser?.cacheKey === cacheKey) { const updatedUser = await db.mediaData.get(cacheKey); setSelectedUser(updatedUser); const activeFilters = Object.values(mediaFilters).some((v) => v); const timelineToCheck = activeFilters ? updatedUser.data.timeline.filter((media) => { if (mediaFilters.photo && media.type === "photo") return true; if (mediaFilters.video && media.type === "video") return true; if ( mediaFilters.animated_gif && media.type === "animated_gif" ) return true; return false; }) : updatedUser.data.timeline; const newTotalPages = Math.ceil( timelineToCheck.length / itemsPerPage ); if (currentPage > newTotalPages && newTotalPages > 0) { setCurrentPage(newTotalPages); } } } } } catch (error) { console.error("Failed to delete:", error); } setShowDeleteAlert(false); setDeleteTarget(null); }; const handleClearAllConfirm = async () => { try { await db.mediaData.clear(); await loadCachedUsers(); setSelectedUser(null); setCurrentPage(1); setCurrentAccountPage(1); state.mediaData.value = null; state.selectedCacheUser.value = null; } catch (error) { console.error("Failed to clear cached media data:", error); } setShowClearAllAlert(false); }; const handleShredListConfirm = async () => { try { if (selectedUser && selectedUser.cacheKey) { const cacheKey = selectedUser.cacheKey; await db.mediaData.delete(cacheKey); await loadCachedUsers(); setSelectedUser(null); setCurrentPage(1); } } catch (error) { console.error("Failed to shred media list:", error); } setShowShredListAlert(false); }; const handleDeleteCancel = () => { setShowDeleteAlert(false); setDeleteTarget(null); }; const downloadSingleMedia = async (media, index) => { try { const date = dayjs(media.date).format("YYYY-MM-DD_HHmmss"); const ext = media.type === "video" ? "mp4" : media.type === "animated_gif" ? "mp4" : "jpg"; const actualUsername = selectedUser.data?.account_info?.name || selectedUser.username || "unknown"; const filename = `${date}_${actualUsername}_${media.tweet_id}_${index}.${ext}`; console.log(`Downloading single file: ${filename}`); const response = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Download timeout")); }, 60000); GM_xmlhttpRequest({ method: "GET", url: media.url, responseType: "blob", onload: (res) => { clearTimeout(timeout); if (res.status === 200 && res.response) { resolve(res.response); } else { reject(new Error(`HTTP ${res.status}`)); } }, onerror: (err) => { clearTimeout(timeout); reject(err); }, ontimeout: () => { clearTimeout(timeout); reject(new Error("Request timeout")); }, }); }); saveAs(response, filename); console.log(`✓ Downloaded: ${filename}`); } catch (error) { console.error("Failed to download single media:", error); alert(`Failed to download media: ${error.message}`); } }; const formatNumber = (num) => { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); }; const getTimeAgo = (timestamp) => { const now = Date.now(); const diff = now - timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) { const remainingHours = hours % 24; return `${days}d ${remainingHours}h ago`; } else if (hours > 0) { const remainingMinutes = minutes % 60; return `${hours}h ${remainingMinutes}m ago`; } else if (minutes > 0) { return `${minutes}m ago`; } else { return "just now"; } }; const getMediaIcon = (type) => { switch (type) { case "photo": return ICONS.photo.replace( 'stroke="currentColor"', 'stroke="hsl(142.1deg 76.2% 36.3%)"' ); case "video": return ICONS.video.replace( 'stroke="currentColor"', 'stroke="hsl(37.7deg 92.1% 50.2%)"' ); case "animated_gif": return ICONS.animatedGif.replace( 'stroke="currentColor"', 'stroke="hsl(270deg 60% 50%)"' ); default: return ICONS.photo.replace( 'stroke="currentColor"', 'stroke="hsl(142.1deg 76.2% 36.3%)"' ); } }; const filteredTimeline = selectedUser ? (() => { const hasActiveFilter = Object.values(mediaFilters).some((v) => v); if (!hasActiveFilter) { return selectedUser.data.timeline; } return selectedUser.data.timeline.filter((media) => { if (mediaFilters.photo && media.type === "photo") return true; if (mediaFilters.video && media.type === "video") return true; if (mediaFilters.animated_gif && media.type === "animated_gif") return true; return false; }); })() : []; const paginatedMedia = filteredTimeline.slice( (currentPage - 1) * itemsPerPage, currentPage * itemsPerPage ); const totalPages = Math.ceil(filteredTimeline.length / itemsPerPage) || 0; const toggleFilter = (type) => { setMediaFilters((prev) => ({ ...prev, [type]: !prev[type], })); setCurrentPage(1); }; return h( "div", null, showDeleteAlert && h(AlertDialog, { title: deleteTarget?.type === "user" ? "Delete User Cache" : "Delete Media Entry", message: deleteTarget?.type === "user" ? `Are you sure you want to delete all cached data for @<strong>${ typeof deleteTarget.target === "string" ? deleteTarget.target : deleteTarget.target?.username || "unknown" }</strong>?` : "Are you sure you want to delete this media entry?", onConfirm: handleDeleteConfirm, onCancel: handleDeleteCancel, }), showClearAllAlert && h(AlertDialog, { title: "Shred All Cache", message: "WARNING: This will permanently delete ALL cached media data. This action cannot be undone. Are you absolutely sure?", onConfirm: handleClearAllConfirm, onCancel: () => setShowClearAllAlert(false), }), showShredListAlert && h(AlertDialog, { title: "Shred Media List", message: "WARNING: This will permanently delete ALL media items in this cached list. This action cannot be undone. Are you absolutely sure?", onConfirm: handleShredListConfirm, onCancel: () => setShowShredListAlert(false), }), !selectedUser ? h( "div", null, h( "div", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 16px;", }, h( "button", { className: "tmd-button tmd-button-outline tmd-import-button", style: "padding: 6px 12px;", title: "Import database from .db files (supports multiple files)", onClick: () => { const input = document.createElement("input"); input.type = "file"; input.accept = ".db"; input.multiple = true; input.onchange = async (e) => { const files = Array.from(e.target.files); if (files.length > 0) { try { let successCount = 0; let errorCount = 0; const errors = []; state.isLoading.value = true; for (let i = 0; i < files.length; i++) { const file = files[i]; try { await db.import(file, { acceptMissingTables: true, acceptVersionDiff: true, acceptNameDiff: true, overwriteValues: true, }); successCount++; } catch (error) { errorCount++; errors.push(`${file.name}: ${error.message}`); console.error( `Failed to import ${file.name}:`, error ); } } state.isLoading.value = false; await loadCachedUsers(); if (errorCount === 0) { state.success.value = `Successfully imported ${successCount} database${ successCount > 1 ? "s" : "" }!`; } else if (successCount === 0) { state.error.value = `Failed to import all ${errorCount} files. ${errors.join( "; " )}`; state.errorType.value = "general"; } else { state.success.value = `Imported ${successCount} database${ successCount > 1 ? "s" : "" } successfully. ${errorCount} failed: ${errors.join( "; " )}`; } const currentSuccessMessage = state.success.value; const currentErrorMessage = state.error.value; setTimeout(() => { if (state.success.value === currentSuccessMessage) { state.success.value = null; } }, 3000); setTimeout(() => { if (state.error.value === currentErrorMessage) { state.error.value = null; } }, 3000); } catch (error) { state.isLoading.value = false; console.error("Failed to import databases:", error); const errorMessage = `Failed to import: ${error.message}`; state.error.value = errorMessage; state.errorType.value = "general"; setTimeout(() => { if (state.error.value === errorMessage) { state.error.value = null; } }, 3000); } } }; input.click(); }, }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.fileInput, }, }), "Import" ), h( "button", { className: "tmd-button tmd-button-outline tmd-shred-button", style: "padding: 6px 12px;", onClick: () => setShowClearAllAlert(true), title: "Shred all cached data", disabled: !hasAnyDatabase, }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.shredder, }, }), "Shred" ), h( "button", { className: `tmd-button tmd-button-outline ${ state.showBatchDatabase.value ? "tmd-batch-toggle-active" : "" }`, style: `padding: 6px 12px; ${ state.showBatchDatabase.value ? "background: hsl(270deg 60% 50% / 0.15); border-color: hsl(270deg 60% 50%); color: hsl(270deg 60% 50%);" : "" }${ !hasBatchDatabases ? " opacity: 0.5; cursor: not-allowed;" : "" }`, onClick: () => { if (hasBatchDatabases) { state.showBatchDatabase.value = !state.showBatchDatabase.value; saveSettings(); loadCachedUsers(); } }, disabled: !hasBatchDatabases, title: !hasBatchDatabases ? "No batch databases available" : state.showBatchDatabase.value ? "Filter enabled: Showing only batch databases. Click to show all databases" : "Filter disabled: Showing all databases. Click to filter batch databases only", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.layers, }, }), "Batch" ) ), cachedUsers.length === 0 ? h( "div", { style: "text-align: center; padding: 40px 20px; opacity: 0.6;", }, h("div", { dangerouslySetInnerHTML: { __html: ICONS.frown .replace('width="24"', 'width="48"') .replace('height="24"', 'height="48"'), }, style: "display: flex; justify-content: center; margin-bottom: 16px; opacity: 0.5;", }), h( "p", { style: "font-size: 16px;" }, state.showBatchDatabase.value ? "No batch databases available" : "No cached data available" ) ) : (() => { const paginatedAccounts = cachedUsers.slice( (currentAccountPage - 1) * accountsPerPage, currentAccountPage * accountsPerPage ); const totalAccountPages = Math.ceil( cachedUsers.length / accountsPerPage ); return h( "div", null, h( "div", { style: "display: flex; gap: 8px; margin-bottom: 16px;", }, totalAccountPages > 1 && h( "button", { className: "tmd-button tmd-button-outline", style: "padding: 6px 12px;", disabled: !jumpToAccountPage || parseInt(jumpToAccountPage) < 1 || parseInt(jumpToAccountPage) > totalAccountPages, onClick: () => { const page = parseInt(jumpToAccountPage); if (page >= 1 && page <= totalAccountPages) { setCurrentAccountPage(page); setJumpToAccountPage(""); } }, }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.rabbit }, }), "Jump" ), totalAccountPages > 1 && h("input", { type: "number", className: "tmd-input", value: jumpToAccountPage, onInput: (e) => setJumpToAccountPage(e.target.value), placeholder: "", min: 1, max: totalAccountPages, style: "width: 50px; padding: 6px 8px; text-align: center;", }) ), h( "div", { style: "margin-bottom: 20px;" }, paginatedAccounts.map((user) => h( "div", { className: "tmd-info-card clickable", style: "margin-bottom: 12px;", }, h( "div", { style: "display: flex; align-items: center; gap: 12px;", }, user.data.account_info.profile_image && h("img", { src: user.data.account_info.profile_image, style: "width: 56px; height: 56px; border-radius: 50%; object-fit: cover;", }), h( "div", { style: "flex: 1;" }, h( "div", { style: "font-weight: 600; display: flex; align-items: center; gap: 8px;", }, user.data.account_info.nick, user.isBatchGroup && h( "span", { style: "background: hsl(270deg 60% 50% / 0.2); color: hsl(270deg 60% 50%); padding: 2px 8px; border-radius: 4px; font-weight: 500; font-size: 11px;", }, "BATCH" ) ), h( "a", { href: `https://x.com/${user.username}`, target: "_blank", rel: "noopener noreferrer", style: "font-size: 14px; opacity: 0.7; color: inherit; text-decoration: none; display: inline-block; transition: all 0.2s;", onMouseEnter: (e) => { e.target.style.opacity = "1"; e.target.style.color = "hsl(204.17deg 87.55% 52.75%)"; e.target.style.textDecoration = "underline"; }, onMouseLeave: (e) => { e.target.style.opacity = "0.7"; e.target.style.color = "inherit"; e.target.style.textDecoration = "none"; }, onClick: (e) => e.stopPropagation(), }, `@${user.username}` ), h( "div", { style: "font-size: 12px; opacity: 0.5; margin-top: 4px;", }, `Cached: ${dayjs(user.latestTimestamp).format( "DD MMM YYYY HH:mm" )} • ${getTimeAgo(user.latestTimestamp)}` ), h( "div", { style: "display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;", }, user.configs.map((config) => h( "span", { style: ` display: inline-flex; align-items: center; gap: 3px; padding: 2px 6px; font-size: 11px; font-weight: 500; border-radius: 4px; background: ${ config.timelineType === "media" ? "hsl(204.17deg 87.55% 52.75% / 0.15)" : config.timelineType === "timeline" ? "hsl(142.1deg 76.2% 36.3% / 0.15)" : config.timelineType === "tweets" ? "hsl(37.7deg 92.1% 50.2% / 0.15)" : "hsl(270deg 60% 50% / 0.15)" }; color: ${ config.timelineType === "media" ? "hsl(204.17deg 87.55% 52.75%)" : config.timelineType === "timeline" ? "hsl(142.1deg 76.2% 36.3%)" : config.timelineType === "tweets" ? "hsl(37.7deg 92.1% 50.2%)" : "hsl(270deg 60% 50%)" }; border: 1px solid ${ config.timelineType === "media" ? "hsl(204.17deg 87.55% 52.75% / 0.3)" : config.timelineType === "timeline" ? "hsl(142.1deg 76.2% 36.3% / 0.3)" : config.timelineType === "tweets" ? "hsl(37.7deg 92.1% 50.2% / 0.3)" : "hsl(270deg 60% 50% / 0.3)" }; cursor: pointer; transition: all 0.2s; `, onClick: async (e) => { e.stopPropagation(); const cacheData = await db.mediaData.get( config.cacheKey ); if (cacheData) { setSelectedUser(cacheData); setCurrentPage(1); } }, onMouseEnter: (e) => { e.target.style.opacity = "0.8"; }, onMouseLeave: (e) => { e.target.style.opacity = "1"; }, title: `Load ${ config.timelineType } with ${config.mediaType} media (${ config.mediaCount } items)${ config.isBatch ? " - Batch" : "" }`, }, h( "span", null, config.timelineType === "media" ? "Media" : config.timelineType === "timeline" ? "Posts" : config.timelineType === "tweets" ? "Tweets" : "Replies" ), config.mediaType !== "all" && h( "span", { style: "opacity: 0.8; font-weight: 400;", }, config.mediaType === "image" ? "[IMG]" : config.mediaType === "video" ? "[VID]" : "[GIF]" ), h( "span", { style: "opacity: 0.9; font-weight: 600;", }, `(${config.mediaCount})` ) ) ) ) ), h( "div", { style: "display: flex; gap: 8px;" }, user.data && user.data.timeline && user.data.timeline.length > 0 && h("button", { className: "tmd-button tmd-button-outline tmd-button-square", style: "height: 40px;", title: "Preview media gallery", onClick: (e) => { e.stopPropagation(); state.previewMediaData.value = user.data; state.previewCurrentIndex.value = 0; state.previewModalOpen.value = true; }, dangerouslySetInnerHTML: { __html: ICONS.eye, }, }), h("button", { className: "tmd-button tmd-button-outline tmd-button-square tmd-delete-button", style: "height: 40px;", title: "Delete cache", onClick: (e) => { e.stopPropagation(); handleDeleteClick("user", { username: user.username, isBatchGroup: user.isBatchGroup, }); }, dangerouslySetInnerHTML: { __html: ICONS.trash, }, }) ) ) ) ) ), totalAccountPages > 1 && h( "div", { style: "display: flex; justify-content: center; gap: 8px; align-items: center;", }, h("button", { className: "tmd-button tmd-button-outline tmd-button-square", disabled: currentAccountPage === 1, onClick: () => setCurrentAccountPage(1), title: "First page", dangerouslySetInnerHTML: { __html: ICONS.chevronsLeft, }, }), h("button", { className: "tmd-button tmd-button-outline tmd-button-square", disabled: currentAccountPage === 1, onClick: () => setCurrentAccountPage(currentAccountPage - 1), title: "Previous page", dangerouslySetInnerHTML: { __html: ICONS.chevronLeft, }, }), h( "span", { style: "padding: 0 12px; display: flex; align-items: center; font-weight: 500;", }, `${currentAccountPage} / ${totalAccountPages}` ), h("button", { className: "tmd-button tmd-button-outline tmd-button-square", disabled: currentAccountPage === totalAccountPages, onClick: () => setCurrentAccountPage(currentAccountPage + 1), title: "Next page", dangerouslySetInnerHTML: { __html: ICONS.chevronRight, }, }), h("button", { className: "tmd-button tmd-button-outline tmd-button-square", disabled: currentAccountPage === totalAccountPages, onClick: () => setCurrentAccountPage(totalAccountPages), title: "Last page", dangerouslySetInnerHTML: { __html: ICONS.chevronsRight, }, }) ) ); })() ) : h( "div", { className: "tmd-database-content" }, h( "div", { style: "display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;", }, h( "button", { className: "tmd-button tmd-button-outline", style: "padding: 6px 12px;", onClick: () => { setSelectedUser(null); setCurrentPage(1); setJumpToPage(""); }, title: "Back to user list", }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.undo } }), "Back" ), h( "button", { className: "tmd-button tmd-button-outline tmd-load-button", style: "padding: 6px 12px;", title: "Load this cached data to Dashboard", onClick: async () => { if (selectedUser) { state.mediaData.value = selectedUser.data; state.currentUsername.value = selectedUser.username; state.loadedFromDatabase.value = true; state.loadedDatabaseConfig.value = { cacheKey: selectedUser.cacheKey, isBatch: selectedUser.isBatch || (selectedUser.cacheKey && selectedUser.cacheKey.endsWith("_batch")), timelineType: selectedUser.timelineType || parts[1], mediaType: selectedUser.mediaType || parts[2], }; if (selectedUser.cacheKey) { const parts = selectedUser.cacheKey.split("_"); if (parts.length >= 3) { state.timelineType.value = parts[1]; state.mediaType.value = parts[2]; state.loadedDatabaseConfig.value.timelineType = parts[1]; state.loadedDatabaseConfig.value.mediaType = parts[2] === "batch" ? parts[2 - 1] : parts[2]; } } else if ( selectedUser.timelineType && selectedUser.mediaType ) { state.timelineType.value = selectedUser.timelineType; state.mediaType.value = selectedUser.mediaType; } state.activeTab.value = "dashboard"; } }, }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.upload }, }), "Load" ), h( "button", { className: "tmd-button tmd-button-outline tmd-export-button", style: "padding: 6px 12px;", title: "Export this database to .db file", onClick: async () => { if (selectedUser) { try { const tempDb = new Dexie("TempExportDB"); tempDb.version(1).stores({ mediaData: "cacheKey, username, timelineType, mediaType, data, timestamp", }); await tempDb.open(); await tempDb.mediaData.put({ cacheKey: selectedUser.cacheKey, username: selectedUser.username, timelineType: selectedUser.timelineType, mediaType: selectedUser.mediaType, data: selectedUser.data, timestamp: selectedUser.timestamp, isBatch: selectedUser.isBatch || (selectedUser.cacheKey && selectedUser.cacheKey.endsWith("_batch")), }); const blob = await tempDb.export(); const filename = `${selectedUser.username}_${ selectedUser.timelineType || "media" }_${selectedUser.mediaType || "all"}_${dayjs( selectedUser.timestamp ).format("YYYY-MM-DD_HHmmss")}.db`; saveAs(blob, filename); await tempDb.delete(); state.success.value = "Database exported successfully!"; setTimeout(() => { if ( state.success.value === "Database exported successfully!" ) { state.success.value = null; } }, 3000); } catch (error) { console.error("Failed to export database:", error); state.error.value = `Failed to export: ${error.message}`; state.errorType.value = "general"; } } }, }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.fileOutput }, }), "Export" ), h( "button", { className: "tmd-button tmd-button-outline tmd-shred-button", style: "padding: 6px 12px;", title: "Shred: delete all media in this cached list", onClick: () => setShowShredListAlert(true), }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.shredder }, }), "Shred" ), h( "div", { style: "margin-left: auto; display: flex; align-items: center; gap: 8px;", }, h( "button", { className: "tmd-button tmd-button-outline", style: "padding: 6px 12px;", disabled: !jumpToPage || parseInt(jumpToPage) < 1 || parseInt(jumpToPage) > totalPages, onClick: () => { const page = parseInt(jumpToPage); if (page >= 1 && page <= totalPages) { setCurrentPage(page); setJumpToPage(""); } }, }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.rabbit }, }), "Jump" ), h("input", { type: "number", className: "tmd-input", value: jumpToPage, onInput: (e) => setJumpToPage(e.target.value), placeholder: "", min: 1, max: totalPages, style: "width: 50px; padding: 6px 8px; text-align: center;", }) ) ), h( "div", { className: "tmd-info-card", style: "margin-bottom: 8px; background: hsl(204.17deg 87.55% 52.75% / 0.1);", }, h( "div", { style: "display: flex; align-items: center; justify-content: space-between;", }, h( "div", { style: "display: flex; align-items: center; gap: 8px;" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.database.replace( 'stroke="currentColor"', 'stroke="hsl(204.17deg 87.55% 52.75%)"' ), }, style: "opacity: 0.8;", }), h( "div", null, h( "div", { style: "font-size: 14px; font-weight: 600;" }, `${selectedUser.data.account_info.nick}'s ${(() => { if (selectedUser.mediaType) { if (selectedUser.mediaType === "image") return "Images"; if (selectedUser.mediaType === "video") return "Videos"; if (selectedUser.mediaType === "gif") return "GIFs"; } if (selectedUser.cacheKey) { const parts = selectedUser.cacheKey.split("_"); if (parts.length >= 3) { const mediaType = parts[2]; if (mediaType === "image") return "Images"; if (mediaType === "video") return "Videos"; if (mediaType === "gif") return "GIFs"; } } return "Media"; })()}` ), h( "div", { style: "font-size: 12px; opacity: 0.6;" }, filteredTimeline.length === 0 ? "No items match the selected filters" : `Showing ${formatNumber( Math.min( (currentPage - 1) * itemsPerPage + 1, filteredTimeline.length ) )}-${formatNumber( Math.min( currentPage * itemsPerPage, filteredTimeline.length ) )} of ${formatNumber(filteredTimeline.length)} items${ Object.values(mediaFilters).some((v) => v) ? " (filtered)" : "" }` ) ) ), (selectedUser.mediaType === "all" || !selectedUser.mediaType) && h( "div", { style: "display: flex; gap: 4px;" }, h("button", { className: `tmd-button tmd-button-outline tmd-button-square tmd-filter-button tmd-filter-photo`, style: `width: 32px; height: 32px; ${ mediaFilters.photo ? "background: hsl(142.1deg 76.2% 36.3% / 0.15); border-color: hsl(142.1deg 76.2% 36.3%);" : "" }`, onClick: () => toggleFilter("photo"), title: "Filter photos", dangerouslySetInnerHTML: { __html: ICONS.photo.replace( 'stroke="currentColor"', mediaFilters.photo ? 'stroke="hsl(142.1deg 76.2% 36.3%)"' : 'stroke="currentColor"' ), }, }), h("button", { className: `tmd-button tmd-button-outline tmd-button-square tmd-filter-button tmd-filter-video`, style: `width: 32px; height: 32px; ${ mediaFilters.video ? "background: hsl(37.7deg 92.1% 50.2% / 0.15); border-color: hsl(37.7deg 92.1% 50.2%);" : "" }`, onClick: () => toggleFilter("video"), title: "Filter videos", dangerouslySetInnerHTML: { __html: ICONS.video.replace( 'stroke="currentColor"', mediaFilters.video ? 'stroke="hsl(37.7deg 92.1% 50.2%)"' : 'stroke="currentColor"' ), }, }), h("button", { className: `tmd-button tmd-button-outline tmd-button-square tmd-filter-button tmd-filter-gif`, style: `width: 32px; height: 32px; ${ mediaFilters.animated_gif ? "background: hsl(270deg 60% 50% / 0.15); border-color: hsl(270deg 60% 50%);" : "" }`, onClick: () => toggleFilter("animated_gif"), title: "Filter animated GIFs", dangerouslySetInnerHTML: { __html: ICONS.animatedGif.replace( 'stroke="currentColor"', mediaFilters.animated_gif ? 'stroke="hsl(270deg 60% 50%)"' : 'stroke="currentColor"' ), }, }) ) ) ), h( "div", { className: "tmd-media-list-wrapper" }, h( "div", { className: "tmd-media-list-container" }, filteredTimeline.length === 0 ? h( "div", { style: "text-align: center; padding: 40px 20px; opacity: 0.6;", }, h("div", { dangerouslySetInnerHTML: { __html: ICONS.frown .replace('width="24"', 'width="32"') .replace('height="24"', 'height="32"'), }, style: "display: flex; justify-content: center; margin-bottom: 12px; opacity: 0.5;", }), h( "p", { style: "font-size: 14px;" }, Object.values(mediaFilters).some((v) => v) ? "No media matches the selected filters" : "No media available" ) ) : paginatedMedia.map((media) => { const originalIndex = selectedUser.data.timeline.indexOf(media); return h( "div", { className: "tmd-info-card clickable", style: "margin-bottom: 8px;", }, h( "div", { style: "display: flex; align-items: center; justify-content: space-between;", }, h( "div", { style: "display: flex; align-items: center; gap: 8px;", }, h("span", { dangerouslySetInnerHTML: { __html: getMediaIcon(media.type), }, style: "opacity: 0.7;", }), h( "div", null, h( "a", { className: "tmd-tweet-link", href: `https://x.com/${selectedUser.username}/status/${media.tweet_id}`, target: "_blank", rel: "noopener noreferrer", style: `font-size: 14px; display: block; color: ${ media.type === "photo" ? "hsl(142.1deg 76.2% 36.3%)" : media.type === "video" ? "hsl(37.7deg 92.1% 50.2%)" : media.type === "animated_gif" ? "hsl(270deg 60% 50%)" : "hsl(204.17deg 87.55% 52.75%)" };`, title: `View tweet ${media.tweet_id}`, }, media.tweet_id ), h( "div", { style: "font-size: 12px; opacity: 0.6;" }, dayjs(media.date).format("DD MMM YYYY HH:mm") ) ) ), h( "div", { style: "display: flex; gap: 4px;" }, h("button", { className: "tmd-button tmd-button-outline tmd-button-square tmd-preview-button", style: "width: 32px; height: 32px;", title: "Preview media", onClick: () => window.open(media.url, "_blank"), dangerouslySetInnerHTML: { __html: ICONS.eye }, }), h("button", { className: "tmd-button tmd-button-outline tmd-button-square tmd-download-single-button", style: "width: 32px; height: 32px;", title: "Download media", onClick: () => downloadSingleMedia(media, originalIndex), dangerouslySetInnerHTML: { __html: ICONS.download, }, }), h("button", { className: "tmd-button tmd-button-outline tmd-button-square tmd-delete-button", style: "width: 32px; height: 32px;", title: "Delete media", onClick: () => handleDeleteClick("media", { cacheKey: selectedUser.cacheKey, index: originalIndex, }), dangerouslySetInnerHTML: { __html: ICONS.trash }, }) ) ) ); }) ) ), totalPages > 1 && h( "div", { style: "display: flex; justify-content: center; gap: 8px; align-items: center;", }, h("button", { className: "tmd-button tmd-button-outline tmd-button-square", disabled: currentPage === 1, onClick: () => setCurrentPage(1), title: "First page", dangerouslySetInnerHTML: { __html: ICONS.chevronsLeft }, }), h("button", { className: "tmd-button tmd-button-outline tmd-button-square", disabled: currentPage === 1, onClick: () => setCurrentPage(currentPage - 1), title: "Previous page", dangerouslySetInnerHTML: { __html: ICONS.chevronLeft }, }), h( "span", { style: "padding: 0 12px; display: flex; align-items: center; font-weight: 500;", }, `${currentPage} / ${totalPages}` ), h("button", { className: "tmd-button tmd-button-outline tmd-button-square", disabled: currentPage === totalPages, onClick: () => setCurrentPage(currentPage + 1), title: "Next page", dangerouslySetInnerHTML: { __html: ICONS.chevronRight }, }), h("button", { className: "tmd-button tmd-button-outline tmd-button-square", disabled: currentPage === totalPages, onClick: () => setCurrentPage(totalPages), title: "Last page", dangerouslySetInnerHTML: { __html: ICONS.chevronsRight }, }) ) ) ); } function SettingsTab() { return h( "div", null, h( "div", { style: "display: flex; gap: 20px; margin-bottom: 20px;", }, h( "div", { className: "tmd-input-group", style: "width: 180px; margin-bottom: 0;", }, h( "label", { className: "tmd-label" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.layers } }), "Batch Size" ), h("input", { type: "number", className: "tmd-input", value: state.batchSize.value, onInput: (e) => { const value = parseInt(e.target.value); if (value > 0 && value <= 200) { state.batchSize.value = value; saveSettings(); } }, placeholder: "1-200", min: 1, max: 200, style: "padding-right: 12px; width: 100%;", }) ), h( "div", { className: "tmd-input-group", style: "width: 180px; margin-bottom: 0;", }, h( "label", { className: "tmd-label" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.betweenHorizontal }, }), "Starting Batch" ), h("input", { type: "number", className: "tmd-input", value: state.startingBatch.value, onInput: (e) => { const value = parseInt(e.target.value); if (value >= 0) { state.startingBatch.value = value; state.currentBatchPage.value = value; saveSettings(); } }, placeholder: "0-based", min: 0, style: "padding-right: 12px; width: 100%;", }) ) ), h( "div", { className: "tmd-input-group" }, h( "label", { className: "tmd-label" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.twitter } }), "Timeline Type" ), h( "div", { className: "tmd-radio-group", style: "display: flex; gap: 20px; margin-top: 12px; flex-wrap: wrap;", }, h( "div", { className: "tmd-radio-item", onClick: () => { state.timelineType.value = "media"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.timelineType.value === "media" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Media") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.timelineType.value = "timeline"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.timelineType.value === "timeline" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Posts") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.timelineType.value = "tweets"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.timelineType.value === "tweets" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Tweets") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.timelineType.value = "with_replies"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.timelineType.value === "with_replies" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Replies") ) ) ), h( "div", { className: "tmd-input-group" }, h( "label", { className: "tmd-label" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.hardDriveDownload }, }), "Concurrent Limit" ), h( "div", { className: "tmd-radio-group", style: "display: flex; gap: 20px; margin-top: 12px; flex-wrap: wrap;", }, [5, 10, 20, 50, 100].map((limit) => h( "div", { className: "tmd-radio-item", onClick: () => { state.concurrentLimit.value = limit; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.concurrentLimit.value === limit ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, limit.toString()) ) ) ) ), h( "div", { className: "tmd-input-group" }, h( "label", { className: "tmd-label" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.server } }), "Service" ), h( "div", { className: "tmd-radio-group", style: "display: flex; gap: 20px; margin-top: 12px; flex-wrap: wrap;", }, h( "div", { className: "tmd-radio-item", onClick: () => { state.selectedApi.value = "default"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.selectedApi.value === "default" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Default") ), h( "div", { className: "tmd-radio-item", onClick: () => { state.selectedApi.value = "backup"; saveSettings(); }, }, h("div", { className: `tmd-radio ${ state.selectedApi.value === "backup" ? "checked" : "" }`, }), h("span", { className: "tmd-radio-label" }, "Backup") ) ) ), h( "div", { className: "tmd-success" }, h("span", { className: "tmd-success-icon", dangerouslySetInnerHTML: { __html: ICONS.notepadText }, }), h( "div", null, h( "div", { style: "margin-bottom: 8px;" }, "• For accounts with thousands of media: Use ", h( "strong", { style: "color: hsl(204.17deg 87.55% 52.75%);" }, "Batch/Auto Batch" ), " mode if single fetch fails." ), h( "div", { style: "margin-bottom: 8px;" }, "• If Default service fails: Switch to ", h( "strong", { style: "color: hsl(142.1deg 76.2% 36.3%);" }, "Backup" ), " service." ), h( "div", null, h( "strong", { style: "color: hsl(0deg 84.2% 60.2%);" }, "• Warning:" ), " Using more than 20 concurrent downloads may cause some files to fail. Use ", h( "strong", { style: "color: hsl(142.1deg 76.2% 36.3%);" }, "20 or below" ), " for better reliability." ) ) ) ); } function AuthTab() { const [showAuthToken, setShowAuthToken] = useState(false); const [showPatreonAuth, setShowPatreonAuth] = useState(false); const [generateStatus, setGenerateStatus] = useState("idle"); const [verifyStatus, setVerifyStatus] = useState("idle"); const handleAuthTokenChange = (e) => { state.authToken.value = e.target.value; saveSettings(); }; const handlePatreonAuthChange = (e) => { state.patreonAuth.value = e.target.value; state.isVerified.value = false; saveSettings(); }; const verifyPatreonAuth = async () => { if (!state.patreonAuth.value) { state.error.value = "Please enter your Patreon Auth code first"; state.errorType.value = "auth"; setTimeout(() => { if ( state.error.value === "Please enter your Patreon Auth code first" ) { state.error.value = null; } }, 2000); return; } if (state.patreonAuth.value === "xbatchdemo") { state.isVerified.value = true; setVerifyStatus("success"); await saveSettings(); setTimeout(() => { setVerifyStatus("idle"); }, 1000); return; } setVerifyStatus("loading"); state.error.value = null; const api = state.selectedApi.value === "backup" ? "https://backup.xbatch.online" : "https://api.xbatch.online"; const url = `${api}/verify/${state.patreonAuth.value}`; try { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 10000, onload: (res) => { if (res.status === 200) { try { const data = JSON.parse(res.responseText); resolve(data); } catch (parseError) { reject(new Error("Invalid response format")); } } else { reject(new Error(`Verification failed: ${res.status}`)); } }, onerror: () => reject(new Error("Network error")), ontimeout: () => reject(new Error("Request timeout")), }); }); if (response.valid === true) { state.isVerified.value = true; await saveSettings(); setVerifyStatus("success"); setTimeout(() => { setVerifyStatus("idle"); }, 1000); } else { state.isVerified.value = false; await saveSettings(); setVerifyStatus("error"); state.error.value = "Invalid Patreon Auth code"; state.errorType.value = "auth"; setTimeout(() => { if (state.error.value === "Invalid Patreon Auth code") { state.error.value = null; } setVerifyStatus("idle"); }, 2000); } } catch (error) { console.error("Verification failed:", error); state.isVerified.value = false; await saveSettings(); setVerifyStatus("error"); state.error.value = "Verification failed. Please try again."; state.errorType.value = "auth"; setTimeout(() => { if (state.error.value === "Verification failed. Please try again.") { state.error.value = null; } setVerifyStatus("idle"); }, 2000); } }; const generateAuthToken = async () => { if (!state.patreonAuth.value) { state.error.value = "Please enter Patreon Auth first"; state.errorType.value = "auth"; setTimeout(() => { if (state.error.value === "Please enter Patreon Auth first") { state.error.value = null; } }, 2000); return; } if (state.patreonAuth.value === "xbatchdemo") { state.error.value = "Demo code cannot generate auth tokens. For full access, please use a valid Patreon auth code."; state.errorType.value = "auth"; setTimeout(() => { if ( state.error.value === "Demo code cannot generate auth tokens. For full access, please use a valid Patreon auth code." ) { state.error.value = null; } }, 3000); return; } setGenerateStatus("loading"); state.error.value = null; const api = state.selectedApi.value === "backup" ? "https://backup.xbatch.online" : "https://api.xbatch.online"; const url = `${api}/token/${state.patreonAuth.value}`; try { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, timeout: 60000, onload: (res) => { if (res.status === 200) { try { const data = JSON.parse(res.responseText); resolve(data); } catch (parseError) { reject(new Error("Invalid response format")); } } else if (res.status === 401) { reject(new Error("Invalid Patreon Auth")); } else if (res.status === 403) { reject(new Error("Access forbidden")); } else if (res.status === 429) { reject(new Error("Rate limit exceeded")); } else { reject(new Error(`API error: ${res.status}`)); } }, onerror: () => { reject(new Error("Network error")); }, ontimeout: () => { reject(new Error("Request timeout")); }, }); }); if (response.auth_token) { state.authToken.value = response.auth_token; await saveSettings(); setGenerateStatus("success"); setTimeout(() => { setGenerateStatus("idle"); }, 1000); } else { throw new Error("No auth token in response"); } } catch (error) { console.error("Failed to generate auth token:", error); state.error.value = error.message || "Failed to generate auth token"; state.errorType.value = "auth"; setGenerateStatus("error"); setTimeout(() => { if (state.error.value) { state.error.value = null; } }, 2000); setTimeout(() => { setGenerateStatus("idle"); }, 2000); } }; return h( "div", null, state.error.value && state.errorType.value === "auth" && h( "div", { className: "tmd-error auth" }, h("span", { className: "tmd-error-icon", dangerouslySetInnerHTML: { __html: ICONS.triangleAlert }, }), h("span", null, state.error.value) ), h( "div", { className: "tmd-input-group" }, h( "div", { style: "display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;", }, h( "label", { className: "tmd-label", style: "margin-bottom: 0;" }, h("span", { dangerouslySetInnerHTML: { __html: state.isVerified.value ? ICONS.patreonAuthUnlockIcon : ICONS.patreonAuthIcon, }, }), "Patreon Auth" ), h( "button", { className: `tmd-button tmd-button-outline`, onClick: verifyPatreonAuth, disabled: verifyStatus === "loading" || !state.patreonAuth.value || state.patreonAuth.value.trim() === "", style: `padding: 6px 12px; font-size: 13px; ${ verifyStatus === "success" ? "background: hsl(142.1deg 76.2% 36.3% / 0.15); border-color: hsl(142.1deg 76.2% 36.3%); color: hsl(142.1deg 76.2% 36.3%);" : verifyStatus === "error" ? "background: hsl(0deg 84.2% 60.2% / 0.15); border-color: hsl(0deg 84.2% 60.2%); color: hsl(0deg 84.2% 60.2%);" : !state.patreonAuth.value || state.patreonAuth.value.trim() === "" ? "opacity: 0.5; cursor: not-allowed;" : "" }`, title: state.patreonAuth.value && state.patreonAuth.value.trim() !== "" ? "Verify your Patreon Auth" : "Enter Patreon Auth first", }, verifyStatus === "loading" ? h("span", { className: "tmd-spinner", dangerouslySetInnerHTML: { __html: ICONS.spinner }, }) : verifyStatus === "success" ? h("span", { dangerouslySetInnerHTML: { __html: ICONS.checkCircle.replace( 'stroke="currentColor"', 'stroke="hsl(142.1deg 76.2% 36.3%)"' ), }, }) : verifyStatus === "error" ? h("span", { dangerouslySetInnerHTML: { __html: ICONS.shieldX.replace( 'stroke="currentColor"', 'stroke="hsl(0deg 84.2% 60.2%)"' ), }, }) : h("span", { dangerouslySetInnerHTML: { __html: ICONS.shieldCheck }, }), verifyStatus === "loading" ? "Verifying..." : verifyStatus === "success" ? "Verified" : verifyStatus === "error" ? "Failed" : "Verify" ) ), h( "div", { className: "tmd-input-wrapper" }, h("input", { type: showPatreonAuth ? "text" : "password", className: "tmd-input", value: state.patreonAuth.value, onInput: handlePatreonAuthChange, placeholder: "Enter your Patreon auth", }), h("div", { className: "tmd-input-toggle", onClick: () => setShowPatreonAuth(!showPatreonAuth), dangerouslySetInnerHTML: { __html: showPatreonAuth ? ICONS.eyeOff : ICONS.eye, }, }) ) ), h( "div", { className: "tmd-input-group" }, h( "div", { style: "display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;", }, h( "label", { className: "tmd-label", style: "margin-bottom: 0;" }, h("span", { dangerouslySetInnerHTML: { __html: ICONS.authTokenIcon }, }), "Auth Token" ), h( "button", { className: `tmd-button tmd-button-outline`, onClick: generateAuthToken, disabled: generateStatus === "loading" || !state.patreonAuth.value || state.patreonAuth.value.trim() === "" || !state.isVerified.value || state.patreonAuth.value === "xbatchdemo", style: `padding: 6px 12px; font-size: 13px; ${ generateStatus === "success" ? "background: hsl(142.1deg 76.2% 36.3% / 0.15); border-color: hsl(142.1deg 76.2% 36.3%); color: hsl(142.1deg 76.2% 36.3%);" : generateStatus === "error" ? "background: hsl(0deg 84.2% 60.2% / 0.15); border-color: hsl(0deg 84.2% 60.2%); color: hsl(0deg 84.2% 60.2%);" : !state.patreonAuth.value || state.patreonAuth.value.trim() === "" || !state.isVerified.value || state.patreonAuth.value === "xbatchdemo" ? "opacity: 0.5; cursor: not-allowed;" : "" }`, title: !state.patreonAuth.value || state.patreonAuth.value.trim() === "" ? "Enter Patreon Auth first" : state.patreonAuth.value === "xbatchdemo" ? "Demo code cannot generate auth tokens" : !state.isVerified.value ? "Please verify Patreon Auth first" : "Generate new auth token", }, generateStatus === "loading" ? h("span", { className: "tmd-spinner", dangerouslySetInnerHTML: { __html: ICONS.spinner }, }) : generateStatus === "success" ? h("span", { dangerouslySetInnerHTML: { __html: ICONS.checkCircle.replace( 'stroke="currentColor"', 'stroke="hsl(142.1deg 76.2% 36.3%)"' ), }, }) : generateStatus === "error" ? h("span", { dangerouslySetInnerHTML: { __html: ICONS.circleX.replace( 'stroke="currentColor"', 'stroke="hsl(0deg 84.2% 60.2%)"' ), }, }) : h("span", { dangerouslySetInnerHTML: { __html: ICONS.rotateKey }, }), generateStatus === "loading" ? "Generating..." : generateStatus === "success" ? "Generated" : generateStatus === "error" ? "Failed" : "Generate" ) ), h( "div", { className: "tmd-input-wrapper" }, h("input", { type: showAuthToken ? "text" : "password", className: "tmd-input", value: state.authToken.value, onInput: handleAuthTokenChange, placeholder: "Enter your auth token", }), h("div", { className: "tmd-input-toggle", onClick: () => setShowAuthToken(!showAuthToken), dangerouslySetInnerHTML: { __html: showAuthToken ? ICONS.eyeOff : ICONS.eye, }, }) ) ), h( "div", { className: "tmd-success" }, h("span", { className: "tmd-success-icon", dangerouslySetInnerHTML: { __html: ICONS.notepadText }, }), h( "div", null, h( "div", { style: "margin-bottom: 8px;" }, "• Use code ", h( "code", { style: "background: hsl(142.1deg 70% 29% / 0.2); color: hsl(142.1deg 76.2% 36.3%); padding: 2px 6px; border-radius: 4px;", }, "xbatchdemo" ), " for Patreon Auth, then click ", h( "strong", { style: "color: hsl(142.1deg 76.2% 36.3%);" }, "Verify" ), " to unlock demo access. Visit ", h( "a", { href: "https://x.com/xbatchdemo", target: "_blank", rel: "noopener noreferrer", style: "color: hsl(204.17deg 87.55% 52.75%); text-decoration: none;", onMouseEnter: (e) => (e.target.style.textDecoration = "underline"), onMouseLeave: (e) => (e.target.style.textDecoration = "none"), }, "@xbatchdemo" ), " to test." ), h( "div", { style: "margin-bottom: 8px;" }, "• Need help getting Auth Token? ", h( "a", { href: "https://www.patreon.com/posts/how-to-obtain-127206894", target: "_blank", rel: "noopener noreferrer", style: "color: hsl(204.17deg 87.55% 52.75%); text-decoration: none;", onMouseEnter: (e) => (e.target.style.textDecoration = "underline"), onMouseLeave: (e) => (e.target.style.textDecoration = "none"), }, "View the guide here." ) ), h( "div", { style: "margin-bottom: 8px;" }, h( "a", { href: "https://www.patreon.com/exyezed/membership", target: "_blank", rel: "noopener noreferrer", style: "color: hsl(204.17deg 87.55% 52.75%); text-decoration: none;", onMouseEnter: (e) => (e.target.style.textDecoration = "underline"), onMouseLeave: (e) => (e.target.style.textDecoration = "none"), }, "• Subscribe now" ), " to receive your Patreon auth code and start downloading with ease!" ), h( "div", null, "• To report bugs or request features, please contact us at ", h( "a", { href: "mailto:[email protected]", style: "color: hsl(204.17deg 87.55% 52.75%); text-decoration: none;", onMouseEnter: (e) => (e.target.style.textDecoration = "underline"), onMouseLeave: (e) => (e.target.style.textDecoration = "none"), }, "[email protected]" ) ) ) ) ); } function createIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("width", "18"); svg.setAttribute("height", "18"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.style.cursor = "pointer"; svg.style.transition = "color 0.2s"; const paths = [ "M12 15V3", "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4", "m7 10 5 5 5-5", ]; paths.forEach((d) => { const path = document.createElementNS( "http://www.w3.org/2000/svg", "path" ); path.setAttribute("d", d); svg.appendChild(path); }); return svg; } function insertIcons() { document.querySelectorAll('[data-testid="UserName"]').forEach((div) => { if (!div.querySelector(".dl-icon")) { const target = div.querySelector('[aria-label*="verified"]')?.closest("button") ?.parentElement || div.querySelector(".css-1jxf684")?.closest("span"); if (target) { const icon = createIcon(); const wrapper = document.createElement("div"); wrapper.className = "dl-icon"; wrapper.style.cssText = ` display:inline-flex; margin-left:6px; padding:4px; background:hsl(240 3.7% 15.9%); border-radius:4px; transition:background 0.2s; `; wrapper.appendChild(icon); wrapper.onmouseenter = () => { icon.style.color = "hsl(204.17deg 87.55% 52.75%)"; wrapper.style.background = "hsl(240 3.7% 20%)"; }; wrapper.onmouseleave = () => { icon.style.color = ""; wrapper.style.background = "hsl(240 3.7% 15.9%)"; }; wrapper.onclick = (e) => { e.stopPropagation(); e.preventDefault(); let username = null; const urlMatch = window.location.pathname.match( /^\/([^\/?]+)(?:\/|\?|$)/ ); if ( urlMatch && ![ "home", "explore", "notifications", "messages", "bookmarks", "lists", "communities", "premium", "verified-orgs-signup", "settings", "search", "compose", "i", ].includes(urlMatch[1]) ) { username = urlMatch[1]; } if (!username) { const profileLink = div.closest('a[href*="/"]'); if (profileLink) { const usernameMatch = profileLink.href.match( /(?:twitter\.com|x\.com)\/([^\/\?]+)/ ); if ( usernameMatch && ![ "home", "explore", "notifications", "messages", "bookmarks", "lists", "communities", "premium", "verified-orgs-signup", "settings", "search", "compose", "i", ].includes(usernameMatch[1]) ) { username = usernameMatch[1]; } } } if (username) { state.currentUsername.value = username; } state.isModalOpen.value = true; }; target.parentNode.insertBefore(wrapper, target.nextSibling); } } }); } async function init() { await loadSettings(); const modalContainer = document.createElement("div"); modalContainer.id = "tmd-modal-root"; document.body.appendChild(modalContainer); const renderModal = () => { render(h(Modal), modalContainer); }; effect(() => { renderModal(); }); const floatingButton = document.createElement("div"); floatingButton.id = "tmd-floating-button"; floatingButton.style.cssText = ` position: fixed; top: 50%; left: -20px; width: 48px; height: 48px; cursor: pointer; z-index: 9998; transition: all 0.3s ease; opacity: 0.5; `; floatingButton.innerHTML = ICONS.twitter .replace('width="16"', 'width="48"') .replace('height="16"', 'height="48"') .replace('stroke="currentColor"', 'stroke="hsl(204.17deg 87.55% 52.75%)"') .replace( "<svg", '<svg style="transform-origin: bottom center; transition: all 0.3s ease;"' ); floatingButton.onmouseenter = () => { floatingButton.style.transform = "translateX(25px) rotate(20deg)"; floatingButton.style.opacity = "0.9"; const svg = floatingButton.querySelector("svg"); if (svg) { svg.style.transform = "scale(1.1)"; } }; floatingButton.onmouseleave = () => { floatingButton.style.transform = "translateX(0) rotate(0)"; floatingButton.style.opacity = "0.5"; const svg = floatingButton.querySelector("svg"); if (svg) { svg.style.transform = "scale(1)"; } }; floatingButton.onclick = (e) => { e.stopPropagation(); e.preventDefault(); let username = null; const urlMatch = window.location.pathname.match( /^\/([^\/?]+)(?:\/|\?|$)/ ); if ( urlMatch && ![ "home", "explore", "notifications", "messages", "bookmarks", "lists", "communities", "premium", "verified-orgs-signup", "settings", "search", "compose", "i", ].includes(urlMatch[1]) ) { username = urlMatch[1]; } if (username) { state.currentUsername.value = username; } state.isModalOpen.value = true; }; document.body.appendChild(floatingButton); insertIcons(); const observer = new MutationObserver(insertIcons); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();