// ==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();
}
})();