// ==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.2 // @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/dayjs@1.11.15/dayjs.min.js // @require https://cdn.jsdelivr.net/npm/dexie@4.2.0/dist/dexie.min.js // @require https://cdn.jsdelivr.net/npm/file-saver-es@2.0.5/dist/FileSaver.min.js // @require https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.min.js // @require https://cdn.jsdelivr.net/npm/preact@10.27.1/dist/preact.min.js // @require https://cdn.jsdelivr.net/npm/preact@10.27.1/hooks/dist/hooks.umd.js // @require https://cdn.jsdelivr.net/npm/@preact/signals-core@1.12.1/dist/signals-core.min.js // @connect api.xbatch.online // @connect backup.xbatch.online // @connect pbs.twimg.com // @connect video.twimg.com // @downloadURL none // ==/UserScript== (function() { 'use strict'; const { h, render } = preact; const { useState, useEffect, useRef } = preactHooks; const { signal, effect } = preactSignalsCore; const ICONS = { download: ``, hardDriveDownload: ``, cloudCheck: ``, send: ``, layers: ``, betweenHorizontal: ``, play: ``, stop: ``, images: ``, twitter: ``, rotateKey: ``, checkCircle: ``, circleX: ``, rabbit: ``, authTokenIcon: ``, patreonAuthIcon: ``, patreonAuthUnlockIcon: ``, shieldCheck: ``, shieldX: ``, cloudDownload: ``, spinner: ``, sun: ``, moon: ``, close: ``, eye: ``, eyeOff: ``, alert: ``, triangleAlert: ``, notepadText: ``, undo: ``, server: ``, photo: ``, video: ``, animatedGif: ``, trash: ``, database: ``, chevronLeft: ``, chevronRight: ``, chevronsLeft: ``, chevronsRight: ``, shredder: ``, frown: ``, upload: ``, resetIcon: `` }; 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) }; 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-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-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('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' }, 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 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 @${deleteTarget.target}?` : '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-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('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-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 large accounts: 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;' }, h('span', { style: 'color: hsl(204.17deg 87.55% 52.75%);' }, '• '), '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('span', { style: 'color: hsl(204.17deg 87.55% 52.75%);' }, '.') ), 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:support@xbatch.online', 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' }, 'support@xbatch.online') ) ) ) ); } 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(' { 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(); } })();