在您安裝前,Greasy Fork镜像希望您了解本腳本包含“負面功能”,可能幫助腳本的作者獲利,而不能給你帶來任何收益。
此腳本只有在您 付費後才能使用全部的功能。 Greasy Fork镜像尚未支付費用,因此無法驗證使用需要付費的商品,也無法幫助您獲得退款。
腳本的作者解釋:
解鎖 Twitter/X 媒體批量下載器腳本需成為付費會員!加入會員以取得 Patreon 授權碼。
批量下載某個 Twitter/X 帳號的所有圖片和影片(包括受限帳號),原始品質。
// ==UserScript== // @name Twitter/X Media Batch Downloader // @name:en Twitter/X Media Batch Downloader // @name:zh-CN Twitter/X 媒体批量下载器 // @name:zh-TW Twitter/X 媒體批量下載器 // @name:ja Twitter/X メディア一括ダウンローダー // @description Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality. // @description:en Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality. // @description:zh-CN 批量下载某个 Twitter/X 账号的所有图片和视频(包括受限账号),原始质量。 // @description:zh-TW 批量下載某個 Twitter/X 帳號的所有圖片和影片(包括受限帳號),原始品質。 // @description:ja Twitter/X アカウントからすべての画像と動画(制限付きアカウントを含む)を元の品質で一括ダウンロードします。 // @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. // @antifeature:en 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. // @antifeature:zh-CN payment 解锁 Twitter/X 媒体批量下载器脚本需成为付费会员!加入会员以获取 Patreon 授权码。 // @antifeature:zh-TW payment 解鎖 Twitter/X 媒體批量下載器腳本需成為付費會員!加入會員以取得 Patreon 授權碼。 // @antifeature:ja payment Twitter/X メディア一括ダウンローダースクリプトを利用するには有料メンバーになる必要があります。Patreon 会員になって認証コードを取得してください。 // @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.9 // @author afkarxyz // @license MIT // @match https://twitter.com/* // @match https://x.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @require https://cdn.jsdelivr.net/npm/[email protected]/dayjs.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/dexie.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/preact.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/hooks/dist/hooks.umd.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/lucide-preact.min.js // @require https://cdn.jsdelivr.net/npm/@preact/[email protected]/dist/signals-core.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/dexie-export-import.min.js // @connect api.xbatch.online // @connect backup.xbatch.online // @connect pbs.twimg.com // @connect video.twimg.com // ==/UserScript== (function() { 'use strict'; const { h, render } = preact; const { useState, useEffect, useRef } = preactHooks; const { signal, effect } = preactSignalsCore; const Icon = (iconName, { size = 16, color = 'currentColor', className = '', ...props } = {}) => { const IconComponent = LucidePreact[iconName]; if (!IconComponent) { console.warn(`Lucide icon "${iconName}" not found`); return null; } return h(IconComponent, { size, color, class: className, ...props }); }; const renderIcon = (iconName, options = {}) => { const tempDiv = document.createElement('div'); render(Icon(iconName, options), tempDiv); return tempDiv.innerHTML; }; 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,.5);backdrop-filter:blur(4px);z-index:9999;display:flex;align-items:center;justify-content:center;animation:fadeIn .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 .3s ease-out;box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 10px 10px -5px rgba(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% / .5);box-shadow:0 0 0 1px hsl(240 5% 35% / .2),0 20px 25px -5px rgba(0,0,0,.3),0 10px 10px -5px rgba(0,0,0,.2);} .tmd-modal.light{background:#fff;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 .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% / .2);color:hsl(37.7deg 92.1% 50.2%);} .light .tmd-reset-toggle:hover{background:hsl(37.7deg 92.1% 50.2% / .1);color:hsl(37.7deg 92.1% 50.2%);} .tmd-close-btn{padding:8px;border-radius:8px;cursor:pointer;transition:all .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% / .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% / .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 .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 .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:.5;transition:opacity .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:#fff;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 .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:#fff;} .tmd-button-primary:hover{background:hsl(204.17deg 87.55% 45%);} .tmd-button-primary:disabled{opacity:.5;cursor:not-allowed;} .tmd-button-secondary{background:hsl(142.1deg 76.2% 36.3%);color:#fff;} .tmd-button-secondary:hover{background:hsl(142.1deg 76.2% 30%);} .tmd-button-secondary:disabled{opacity:.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:.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% / .1);color:hsl(45deg 100% 45%);} .tmd-error.api,.tmd-error.username{background:hsl(45deg 100% 51% / .1);color:hsl(45deg 100% 45%);} .tmd-error.general{background:hsl(0deg 84.2% 60.2% / .1);color:hsl(0deg 84.2% 60.2%);} .tmd-error.failed{background:hsl(0deg 84.2% 60.2% / .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% / .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 .2s ease;cursor:default;position:relative;z-index:1;} .tmd-info-card.clickable:hover{border-color:hsl(204.17deg 87.55% 52.75%)!important;} .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 .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 .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 .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:#fff;} .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 .3s ease;display:inline-flex;align-items:center;justify-content:center;opacity:.7;} .tmd-icon-button:hover{opacity:1;background:hsl(0deg 84.2% 60.2% / .1);} .tmd-icon-button:hover svg{stroke:hsl(0deg 84.2% 60.2%);transition:stroke .3s ease;} .tmd-delete-button{transition:all .3s ease;} .tmd-delete-button:hover{background:hsl(0deg 84.2% 60.2% / .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 .3s ease;} .tmd-load-button{transition:all .3s ease;} .tmd-load-button:hover{background:hsl(142.1deg 76.2% 36.3% / .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 .3s ease;} .tmd-download-current-button{transition:all .3s ease;} .tmd-download-current-button:hover{background:hsl(142.1deg 76.2% 36.3% / .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 .3s ease;} .tmd-shred-button{transition:all .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% / .1)!important;} .tmd-shred-button:hover svg{stroke:hsl(0deg 84.2% 60.2%);transition:stroke .3s ease;} .tmd-download-single-button{transition:all .3s ease;} .tmd-download-single-button:hover{background:hsl(142.1deg 76.2% 36.3% / .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 .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% / .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% / .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 .2s;} .tmd-tweet-link:hover{opacity:.8;text-decoration:underline;filter:brightness(1.2);} .tmd-filter-button{transition:all .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% / .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% / .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% / .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,.5);backdrop-filter:blur(4px);z-index:10000;display:flex;align-items:center;justify-content:center;animation:fadeIn .2s ease-out;} .tmd-alert{background:#fff;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,.1),0 10px 10px -5px rgba(0,0,0,.04);animation:slideUp .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:.9;} .tmd-alert-buttons{display:flex;gap:12px;justify-content:flex-end;} .tmd-preview-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:10001;display:flex;flex-direction:column;} .tmd-preview-header{display:flex;flex-direction:column;align-items:stretch;gap:16px;padding:20px;border-bottom:1px solid rgba(255,255,255,.15);color:#fff;position:relative;} .tmd-preview-id{display:flex;align-items:center;gap:12px;width:100%;} .tmd-preview-id-text{display:flex;flex-direction:column;min-width:0;} .tmd-preview-id-text .tmd-acc-name{font-weight:700;line-height:1.2;} .tmd-preview-id-text .tmd-acc-username{opacity:.8;font-size:13px;} .tmd-preview-stats{width:100%;color:#fff;opacity:.9;margin-top:8px;display:flex;gap:20px;flex-wrap:wrap;font-size:13px;} .tmd-preview-stats span{white-space:nowrap;} .tmd-preview-stats strong{font-weight:700;} .tmd-preview-toolbar{display:flex;align-items:center;gap:12px;justify-content:center;margin-top:12px;width:100%;max-width:600px;margin-left:auto;margin-right:auto;} .tmd-preview-toolbar .tmd-filter-chip{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 20px;border-radius:20px;border:1px solid rgba(255,255,255,.25);color:#fff;cursor:pointer;opacity:.85;user-select:none;transition:all .2s;flex:1;font-size:14px;min-width:120px;} .tmd-preview-toolbar .tmd-filter-chip.active{background:rgba(255,255,255,.12);opacity:1;} .tmd-preview-content{flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;touch-action:pan-y;padding:80px 40px 90px 40px;} .tmd-preview-media{max-width:calc(100vw - 100px);max-height:calc(100vh - 340px);object-fit:contain;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.5);} .tmd-preview-nav{position:absolute;top:50%;transform:translateY(-50%);width:48px;height:48px;border-radius:50%;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.3);color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;user-select:none;} .tmd-preview-nav:hover{background:rgba(255,255,255,.18);} .tmd-preview-nav.prev{left:16px;} .tmd-preview-nav.next{right:16px;} .tmd-preview-close{position:absolute;top:20px;right:20px;padding:8px;border-radius:8px;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;background:hsl(240 3.7% 15.9%);color:#fff;z-index:10;} .tmd-preview-close:hover{background:hsl(0deg 84.2% 60.2% / .2);} .tmd-preview-close:hover svg{stroke:hsl(0deg 84.2% 60.2%);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-photo:not(.active){border-color:rgba(255,255,255,.25);background:transparent;} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-photo:not(.active) svg{stroke:hsl(142.1deg 76.2% 36.3%);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-photo:not(.active):hover{border-color:hsl(142.1deg 76.2% 36.3%);background:hsl(142.1deg 76.2% 36.3% / .1);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-photo.active{border-color:hsl(142.1deg 76.2% 36.3%);background:hsl(142.1deg 76.2% 36.3% / .15);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-photo.active svg{stroke:hsl(142.1deg 76.2% 36.3%);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-photo.active:hover{background:hsl(142.1deg 76.2% 36.3% / .25);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-video:not(.active){border-color:rgba(255,255,255,.25);background:transparent;} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-video:not(.active) svg{stroke:hsl(37.7deg 92.1% 50.2%);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-video:not(.active):hover{border-color:hsl(37.7deg 92.1% 50.2%);background:hsl(37.7deg 92.1% 50.2% / .1);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-video.active{border-color:hsl(37.7deg 92.1% 50.2%);background:hsl(37.7deg 92.1% 50.2% / .15);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-video.active svg{stroke:hsl(37.7deg 92.1% 50.2%);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-video.active:hover{background:hsl(37.7deg 92.1% 50.2% / .25);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-gif:not(.active){border-color:rgba(255,255,255,.25);background:transparent;} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-gif:not(.active) svg{stroke:hsl(270deg 60% 50%);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-gif:not(.active):hover{border-color:hsl(270deg 60% 50%);background:hsl(270deg 60% 50% / .1);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-gif.active{border-color:hsl(270deg 60% 50%);background:hsl(270deg 60% 50% / .15);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-gif.active svg{stroke:hsl(270deg 60% 50%);} .tmd-preview-toolbar .tmd-filter-chip.tmd-filter-gif.active:hover{background:hsl(270deg 60% 50% / .25);} .tmd-preview-counter{position:absolute;top:15px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.8);color:#fff;padding:6px 12px;border-radius:16px;font-size:13px;backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);font-weight:500;} .tmd-preview-date{position:absolute;bottom:15px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.8);color:#fff;padding:8px 14px;border-radius:20px;font-size:14px;backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);} .tmd-alert-button{padding:8px 16px;border-radius:8px;font-weight:500;cursor:pointer;transition:all .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:#fff;} .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;} .tmd-database-buttons{display:flex;align-items:center;justify-content:space-between;gap:8px;} .tmd-database-buttons .tmd-button-outline{height:40px;padding:6px 12px;white-space:nowrap;} .tmd-database-buttons-right{display:flex;align-items:center;gap:8px;} @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-buttons{display:flex!important;flex-wrap:wrap!important;gap:4px!important;width:100%!important;justify-content:stretch!important}.tmd-database-buttons .tmd-button-outline{height:40px!important;padding:6px 8px!important;font-size:12px!important;min-width:0!important;flex:1!important;white-space:nowrap!important;display:flex!important;align-items:center!important;justify-content:center!important;gap:4px!important}.tmd-database-buttons .tmd-button-outline span{font-size:12px!important}.tmd-database-buttons .tmd-button-outline svg{width:14px!important;height:14px!important}.tmd-database-buttons-right{display:contents!important}.tmd-database-content input[type="number"]{width:45px!important;padding:4px 6px!important}.tmd-preview-media{max-width:calc(100vw - 32px)!important;max-height:calc(100vh - 200px)!important}.tmd-preview-counter{top:12px!important;padding:4px 8px!important;font-size:12px!important;border-radius:12px!important}.tmd-preview-date{bottom:12px!important;padding:6px 10px!important;font-size:13px!important;border-radius:16px!important}.tmd-preview-nav{width:40px!important;height:40px!important}.tmd-preview-nav.prev{left:8px!important}.tmd-preview-nav.next{right:8px!important}.tmd-preview-toolbar .tmd-filter-chip{padding:6px 10px!important;font-size:12px!important;height:28px!important;min-width:90px!important}.tmd-preview-toolbar .tmd-filter-chip svg{width:12px!important;height:12px!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(); 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 = 'Settings reset completed successfully!'; setTimeout(() => { if (state.success.value === 'Settings reset completed successfully!') { state.success.value = null; } }, 2000); state.activeTab.value = 'auth'; } catch (error) { console.error('Failed to reset settings:', error); state.error.value = 'Failed to reset settings. Please try again.'; state.errorType.value = 'general'; setTimeout(() => { if (state.error.value === 'Failed to reset settings. Please try again.') { state.error.value = null; } }, 2000); } }; if (!state.isModalOpen.value) return null; return h('div', null, showResetConfirm && h(AlertDialog, { title: 'Reset Settings', message: 'This will reset all settings to default values. Database will be preserved. Are you 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: renderIcon(state.theme.value === 'dark' ? 'Sun' : 'Moon') }, title: 'Toggle theme' }), h('div', { className: 'tmd-theme-toggle tmd-reset-toggle', onClick: () => setShowResetConfirm(true), dangerouslySetInnerHTML: { __html: renderIcon('RotateCcw') }, title: 'Reset Settings - Reset all settings to default values' }), h('div', { className: 'tmd-close-btn', onClick: () => state.isModalOpen.value = false, dangerouslySetInnerHTML: { __html: renderIcon('X') } }) ) ), 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: renderIcon('CheckCircle') } }), h('span', null, state.success.value) ), state.error.value && (state.errorType.value === 'general' || state.errorType.value === 'username' || state.errorType.value === 'api' || state.errorType.value === 'failed') && h('div', { className: `tmd-error ${state.errorType.value}` }, h('span', { className: 'tmd-error-icon', dangerouslySetInnerHTML: { __html: state.errorType.value === 'username' || state.errorType.value === 'api' ? renderIcon('TriangleAlert') : state.errorType.value === 'failed' ? renderIcon('XCircle') : renderIcon('AlertCircle') } }), 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); const [isVerified, setIsVerified] = useState(state.isVerified.value); useEffect(() => { const cleanupDownloadedFiles = effect(() => { setCurrentFiles(state.downloadedFiles.value); }); const cleanupTotalFileSize = effect(() => { setCurrentSize(state.totalFileSize.value); }); const cleanupIsVerified = effect(() => { setIsVerified(state.isVerified.value); }); return () => { if (typeof cleanupDownloadedFiles === 'function') { cleanupDownloadedFiles(); } if (typeof cleanupTotalFileSize === 'function') { cleanupTotalFileSize(); } if (typeof cleanupIsVerified === 'function') { cleanupIsVerified(); } }; }, []); const fetchBatchMediaData = async (page = 0, _isRetry = false) => { if (state.fetchMode.value === 'cache') { 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.authToken.value || !state.currentUsername.value || !state.patreonAuth.value) { state.error.value = 'Please configure Auth Token in the Auth tab'; state.errorType.value = 'general'; setTimeout(() => { if (state.error.value === 'Please configure Auth Token in the Auth tab') { 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; } }, 2000); 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 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; if (isBatch) { state.fetchType.value = 'autoBatch'; } else { state.fetchType.value = 'single'; } state.timelineType.value = timelineType; state.mediaType.value = mediaType; } state.isLoading.value = true; state.error.value = null; try { if (state.loadedDatabaseConfig.value && state.loadedDatabaseConfig.value.isBatch) { state.currentBatchPage.value = 0; state.batchedMediaData.value = []; state.isAutoBatch.value = true; let currentPage = 0; let allData = []; let accountInfo = null; while (state.isAutoBatch.value) { const metadata = await fetchBatchMediaData(currentPage); if (state.mediaData.value && state.mediaData.value.account_info) { accountInfo = state.mediaData.value.account_info; } if (state.currentBatchData.value && state.currentBatchData.value.length > 0) { allData = [...allData, ...state.currentBatchData.value]; } if (!metadata || !metadata.has_more) { state.isAutoBatch.value = false; break; } currentPage++; await new Promise(resolve => setTimeout(resolve, 500)); } if (allData.length > 0 && accountInfo) { const combinedResponse = { account_info: accountInfo, timeline: allData, metadata: { has_more: false } }; state.mediaData.value = combinedResponse; state.batchedMediaData.value = allData; if (state.loadedDatabaseConfig.value && state.loadedDatabaseConfig.value.cacheKey) { const { cacheKey } = state.loadedDatabaseConfig.value; await db.mediaData.put({ cacheKey: cacheKey, username: state.currentUsername.value.toLowerCase(), timelineType: state.timelineType.value, mediaType: state.mediaType.value, data: combinedResponse, timestamp: Date.now(), isBatch: true }); } } } else { 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 } = 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: false }); } } else { throw new Error('No data received from server'); } } state.success.value = 'Database updated successfully!'; setTimeout(() => { if (state.success.value === 'Database updated successfully!') { state.success.value = null; } }, 2000); } 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') { 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.authToken.value || !state.currentUsername.value || !state.patreonAuth.value) { state.error.value = 'Please configure Auth Token in the Auth tab'; state.errorType.value = 'general'; setTimeout(() => { if (state.error.value === 'Please configure Auth Token in the Auth tab') { 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); if (data.error || data.message) { state.isLoading.value = false; reject(new Error(data.error || data.message)); } else { resolve(data); } } catch (parseError) { state.isLoading.value = false; if (res.responseText && res.responseText.includes('<') && res.responseText.includes('>')) { reject(new Error('Invalid authentication - API returned HTML instead of JSON')); } else { reject(new Error('Invalid JSON response from API')); } } } 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; if (!response) { state.error.value = 'No data received from API. Please check your Auth Token.'; } else if (!response.account_info && !response.timeline) { state.error.value = 'Invalid Auth Token or Patreon Auth. Please check your credentials.'; } else if (!response.account_info) { state.error.value = 'Cannot retrieve account info. Please verify your Auth Token.'; } else { state.error.value = 'Cannot retrieve media timeline. Please try again.'; } state.errorType.value = 'api'; setTimeout(() => { 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; } }, 2000); 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); state.isLoading.value = false; if (error.message.includes('Invalid authentication')) { state.error.value = 'Invalid Auth Token. Please generate a new one in the Auth tab.'; state.errorType.value = 'general'; } else if (error.message.includes('Invalid JSON response')) { state.error.value = 'API returned invalid data. Please check your Auth Token.'; state.errorType.value = 'api'; } else if (error.message.includes('Access forbidden')) { state.error.value = 'Access denied. Please verify your Patreon Auth and regenerate Auth Token.'; state.errorType.value = 'general'; } 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(() => { 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: renderIcon('Undo2') } }), '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 || !isVerified, title: ( state.loadedDatabaseConfig.value && state.loadedDatabaseConfig.value.isBatch ? 'Update database with fresh data using auto batch system' : 'Update database with fresh data using single fetch' ) }, state.isLoading.value ? h('span', { className: 'tmd-spinner', dangerouslySetInnerHTML: { __html: renderIcon('Loader2') } }) : h('span', { dangerouslySetInnerHTML: { __html: renderIcon('CloudUpload') } }), 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: renderIcon('Play') } }), 'Start' ) : h('button', { className: 'tmd-button tmd-button-outline tmd-button-stop', onClick: stopAutoBatch, style: 'padding: 6px 12px;' }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Square') } }), 'Stop' ) ) : [ h('button', { className: 'tmd-button tmd-button-outline tmd-button-square', onClick: handlePreviousBatch, disabled: state.currentBatchPage.value <= state.startingBatch.value || state.isLoading.value || !isVerified, title: 'Previous batch', dangerouslySetInnerHTML: { __html: state.loadingDirection.value === 'prev' ? renderIcon('Loader2', { className: 'tmd-spinner' }) : renderIcon('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) || !isVerified, title: 'Next batch', dangerouslySetInnerHTML: { __html: state.loadingDirection.value === 'next' ? renderIcon('Loader2', { className: 'tmd-spinner' }) : renderIcon('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: renderIcon('Send') } }), 'Fetch' ), 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: renderIcon('Database') } }), '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: renderIcon('Images') } }), 'Media' ), 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') ) ) ) ), (!isVerified && state.fetchMode.value === 'fresh' && !state.mediaData.value) && h('div', { className: 'tmd-error auth' }, h('span', { className: 'tmd-error-icon', dangerouslySetInnerHTML: { __html: renderIcon('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 || !isVerified, style: 'font-size: 16px; padding: 12px 24px;' }, state.isLoading.value ? h('span', { className: 'tmd-spinner', dangerouslySetInnerHTML: { __html: renderIcon('Loader2', { size: 20 }) } }) : (!isVerified ? h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Lock', { size: 20 }) } }) : h('span', { dangerouslySetInnerHTML: { __html: renderIcon('CloudDownload', { size: 20 }) } }) ), state.isLoading.value ? 'Fetching...' : 'Fetch Media' ), h('button', { className: 'tmd-button tmd-button-secondary', onClick: () => { if (state.patreonAuth.value && state.authToken.value && state.currentUsername.value) { const url = `https://gif.xbatch.online/${state.patreonAuth.value}/${state.authToken.value}/${state.currentUsername.value}`; window.open(url, '_blank'); } else { state.error.value = 'Please configure Auth Token in the Auth tab'; state.errorType.value = 'general'; setTimeout(() => { if (state.error.value === 'Please configure Auth Token in the Auth tab') { state.error.value = null; } }, 2000); } }, disabled: !state.currentUsername.value || !isVerified, style: 'font-size: 16px; padding: 12px 24px;' }, !isVerified ? h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Lock', { size: 20 }) } }) : h('span', { dangerouslySetInnerHTML: { __html: renderIcon('ImagePlay', { size: 20, color: '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 || !isVerified, style: 'padding: 10px 20px;' }, state.isDownloadingCurrent.value ? h('span', { className: 'tmd-spinner', dangerouslySetInnerHTML: { __html: renderIcon('Loader2', { size: 20 }) } }) : h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Download', { size: 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: renderIcon('Loader2', { size: 20 }) } }) : h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Download', { size: 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 [showClearAllAlert, setShowClearAllAlert] = useState(false); const [showShredListAlert, setShowShredListAlert] = useState(false); const [hasBatchDatabases, setHasBatchDatabases] = useState(false); const [hasAnyDatabase, setHasAnyDatabase] = useState(false); const [showPreviewModal, setShowPreviewModal] = useState(false); const [previewData, setPreviewData] = useState(null); const [previewIndex, setPreviewIndex] = useState(0); const [previewFilters, setPreviewFilters] = useState({ photo: true, video: true, animated_gif: true }); 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 renderIcon('Image', { color: 'hsl(142.1deg 76.2% 36.3%)' }); case 'video': return renderIcon('Video', { color: 'hsl(37.7deg 92.1% 50.2%)' }); case 'animated_gif': return renderIcon('ImagePlay', { color: 'hsl(270deg 60% 50%)' }); default: return renderIcon('Image', { color: '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' ? (() => { const targetUsername = typeof deleteTarget.target === 'string' ? deleteTarget.target : deleteTarget.target?.username || ''; return h('span', null, 'Are you sure you want to delete all cached data for @', h('strong', null, targetUsername), '?' ); })() : 'Are you sure you want to delete this media entry?', onConfirm: handleDeleteConfirm, onCancel: handleDeleteCancel }), showClearAllAlert && h(AlertDialog, { title: 'Shred All Cache', message: h('span', null, h('strong', null, '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: h('span', null, h('strong', null, '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; justify-content: space-between; margin-bottom: 16px;' }, h('div', { style: 'display: flex; align-items: center; gap: 8px;' }, h('button', { className: 'tmd-button tmd-button-outline', style: 'padding: 6px 12px;', onClick: async (e) => { e.stopPropagation(); const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.multiple = true; input.style.display = 'none'; input.onchange = async () => { const files = Array.from(input.files || []); if (files.length === 0) return; try { for (const f of files) { const text = await f.text(); let importData; try { importData = JSON.parse(text); } catch (parseErr) { throw new Error(`Invalid JSON file: ${f.name}`); } if (importData.tables) { if (Array.isArray(importData.tables.mediaData)) { await db.mediaData.bulkPut(importData.tables.mediaData); } if (Array.isArray(importData.tables.settings)) { await db.settings.bulkPut(importData.tables.settings); } } else if (importData.data && importData.data.data) { for (const table of importData.data.data) { if (table.tableName === 'mediaData' && table.rows) { await db.mediaData.bulkPut(table.rows.map(row => row.data || row)); } else if (table.tableName === 'settings' && table.rows) { await db.settings.bulkPut(table.rows.map(row => row.data || row)); } } } else if (importData.mediaData || importData.settings) { if (Array.isArray(importData.mediaData)) { await db.mediaData.bulkPut(importData.mediaData); } if (Array.isArray(importData.settings)) { await db.settings.bulkPut(importData.settings); } } else { throw new Error(`Unsupported backup format in file: ${f.name}`); } } await loadCachedUsers(); state.success.value = `Imported ${files.length} file(s) successfully!`; setTimeout(() => { if (state.success.value && state.success.value.startsWith('Imported ')) { state.success.value = null; } }, 2000); } catch (err) { console.error('Import failed:', err); state.error.value = `Import failed: ${err.message || 'Unknown error'}`; state.errorType.value = 'general'; setTimeout(() => { if (state.error.value && state.error.value.startsWith('Import failed:')) { state.error.value = null; } }, 3000); } finally { input.remove(); } }; document.body.appendChild(input); input.click(); }, title: 'Import database backup(s)' }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('FileInput') } }), 'Import' ), h('button', { className: 'tmd-button tmd-button-outline tmd-shred-button', style: 'padding: 6px 12px;', onClick: () => setShowClearAllAlert(true), title: 'Shred all cached data', disabled: !hasAnyDatabase }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('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: renderIcon('Layers') } }), 'Batch' ) ), cachedUsers.length === 0 ? ( h('div', { style: 'text-align: center; padding: 40px 20px; opacity: 0.6;' }, h('div', { dangerouslySetInnerHTML: { __html: renderIcon('Frown', { size: 32 }) }, 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: '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: 4px;' }, 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;' }, `(${formatNumber(config.mediaCount)})`) ) ) ) ), h('button', { className: 'tmd-button tmd-button-outline tmd-button-square', style: 'height: 40px;', title: 'Preview media gallery', onClick: async (e) => { e.stopPropagation(); let dataToUse = user.data; if (!dataToUse?.timeline) { const firstConfig = user.configs?.[0]; if (firstConfig?.cacheKey) { const cacheData = await db.mediaData.get(firstConfig.cacheKey); if (cacheData) dataToUse = cacheData.data; } } setPreviewData({ account_info: dataToUse?.account_info || user.data?.account_info || {}, timeline: dataToUse?.timeline || [] }); setPreviewIndex(0); setPreviewFilters({ photo: true, video: true, animated_gif: true }); setShowPreviewModal(true); }, dangerouslySetInnerHTML: { __html: renderIcon('Eye') } }), h('button', { className: 'tmd-button tmd-button-outline tmd-button-square tmd-delete-button', style: 'height: 40px;', title: 'Delete cache', onClick: (e) => { e.stopPropagation(); handleDeleteClick('user', { username: user.username, isBatchGroup: user.isBatchGroup }); }, dangerouslySetInnerHTML: { __html: renderIcon('Trash2') } }) ) )) ), 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: renderIcon('ChevronsLeft') } }), h('button', { className: 'tmd-button tmd-button-outline tmd-button-square', disabled: currentAccountPage === 1, onClick: () => setCurrentAccountPage(currentAccountPage - 1), title: 'Previous page', dangerouslySetInnerHTML: { __html: renderIcon('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: renderIcon('ChevronRight') } }), h('button', { className: 'tmd-button tmd-button-outline tmd-button-square', disabled: currentAccountPage === totalAccountPages, onClick: () => setCurrentAccountPage(totalAccountPages), title: 'Last page', dangerouslySetInnerHTML: { __html: renderIcon('ChevronsRight') } }) ) ); })() ) ) : ( h('div', { className: 'tmd-database-content' }, h('div', { className: 'tmd-database-buttons', style: 'margin-bottom: 16px;' }, h('button', { className: 'tmd-button tmd-button-outline', onClick: () => { setSelectedUser(null); setCurrentPage(1); }, title: 'Back to user list' }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Undo2') } }), 'Back' ), h('div', { className: 'tmd-database-buttons-right' }, h('button', { className: 'tmd-button tmd-button-outline tmd-load-button', 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: renderIcon('Upload') } }), 'Load' ), h('button', { className: 'tmd-button tmd-button-outline', title: 'Export entire database to a JSON backup', onClick: async () => { try { const exportData = { databaseName: db.name, version: db.verno, exportDate: new Date().toISOString(), tables: {} }; const settings = await db.settings.toArray(); exportData.tables.settings = settings; if (selectedUser && selectedUser.cacheKey) { const userData = await db.mediaData.get(selectedUser.cacheKey); exportData.tables.mediaData = userData ? [userData] : []; } else { const mediaData = await db.mediaData.toArray(); exportData.tables.mediaData = mediaData; } const jsonString = JSON.stringify(exportData, null, 2); const blob = new Blob([jsonString], { type: 'application/json' }); const uname = (typeof selectedUser?.username === 'string' && selectedUser.username) ? selectedUser.username : (state.currentUsername.value || 'database'); const ts = dayjs().format('YYYYMMDD_HHmmss'); const isBatch = selectedUser?.isBatch || (selectedUser?.cacheKey && selectedUser.cacheKey.endsWith('_batch')); let mediaType = 'all'; if (selectedUser?.mediaType) { mediaType = selectedUser.mediaType; } else if (selectedUser?.cacheKey) { const parts = selectedUser.cacheKey.split('_'); if (parts.length >= 3) { mediaType = parts[2] === 'batch' ? (parts.length >= 4 ? parts[2] : 'all') : parts[2]; } } let fileName = `${uname}`; if (isBatch) { fileName += '_batch'; } fileName += `_${mediaType}_${ts}.json`; saveAs(blob, fileName); state.success.value = `Exported database as ${fileName}`; setTimeout(() => { if (state.success.value && state.success.value.startsWith('Exported database')) { state.success.value = null; } }, 2000); } catch (err) { console.error('Export failed:', err); state.error.value = `Export failed: ${err.message || 'Unknown error'}`; state.errorType.value = 'general'; setTimeout(() => { if (state.error.value && state.error.value.startsWith('Export failed:')) { state.error.value = null; } }, 3000); } } }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('FileOutput') } }), 'Export' ), h('button', { className: 'tmd-button tmd-button-outline tmd-shred-button', title: 'Shred: delete all media in this cached list', onClick: () => setShowShredListAlert(true) }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Shredder') } }), 'Shred' ) ) ), 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: renderIcon('Database', { color: '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: renderIcon('Image', { color: mediaFilters.photo ? 'hsl(142.1deg 76.2% 36.3%)' : '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: renderIcon('Video', { color: mediaFilters.video ? 'hsl(37.7deg 92.1% 50.2%)' : '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: renderIcon('ImagePlay', { color: mediaFilters.animated_gif ? 'hsl(270deg 60% 50%)' : '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: renderIcon('Frown', { size: 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: renderIcon('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: renderIcon('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: renderIcon('Trash2') } }) ) ) ); }) ) ), 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: renderIcon('ChevronsLeft') } }), h('button', { className: 'tmd-button tmd-button-outline tmd-button-square', disabled: currentPage === 1, onClick: () => setCurrentPage(currentPage - 1), title: 'Previous page', dangerouslySetInnerHTML: { __html: renderIcon('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: renderIcon('ChevronRight') } }), h('button', { className: 'tmd-button tmd-button-outline tmd-button-square', disabled: currentPage === totalPages, onClick: () => setCurrentPage(totalPages), title: 'Last page', dangerouslySetInnerHTML: { __html: renderIcon('ChevronsRight') } }) ) ) ), showPreviewModal && previewData && h(PreviewModal, { data: previewData, index: previewIndex, onClose: () => setShowPreviewModal(false), onIndexChange: (i) => setPreviewIndex(i), filters: previewFilters, setFilters: setPreviewFilters }) ); } function PreviewModal({ data, index, onClose, onIndexChange, filters, setFilters }) { const containerRef = useRef(null); const [touchStartX, setTouchStartX] = useState(null); const [, setTouchEndX] = useState(null); const formatNumber = (num) => { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); }; const allTimeline = Array.isArray(data?.timeline) ? data.timeline : []; const filtered = allTimeline.filter(m => (filters.photo && m.type === 'photo') || (filters.video && m.type === 'video') || (filters.animated_gif && m.type === 'animated_gif') ); const counts = allTimeline.reduce((acc, m) => { acc.total++; if (m.type === 'photo') acc.photo++; else if (m.type === 'video') acc.video++; else if (m.type === 'animated_gif') acc.animated_gif++; return acc; }, { photo: 0, video: 0, animated_gif: 0, total: 0 }); const current = filtered[Math.max(0, Math.min(index, filtered.length - 1))]; useEffect(() => { function onKey(e) { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowRight') next(); if (e.key === 'ArrowLeft') prev(); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [index, filtered.length]); const onTouchStart = (e) => setTouchStartX(e.changedTouches[0].clientX); const onTouchEnd = (e) => { setTouchEndX(e.changedTouches[0].clientX); const delta = e.changedTouches[0].clientX - (touchStartX ?? e.changedTouches[0].clientX); const threshold = 40; if (delta < -threshold) next(); if (delta > threshold) prev(); }; const next = () => onIndexChange(Math.min(index + 1, filtered.length - 1)); const prev = () => onIndexChange(Math.max(index - 1, 0)); const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] })); const acc = data?.account_info || {}; const profileImg = acc.profile_image || acc.profile_image_url || ''; const displayName = acc.name || acc.nick || ''; const username = acc.nick || acc.name || ''; const followers = acc.followers_count ?? acc.followers ?? 0; const following = acc.friends_count ?? acc.following ?? 0; return h('div', { className: 'tmd-preview-overlay' }, h('div', { className: 'tmd-preview-header' }, h('div', { className: 'tmd-preview-id' }, profileImg && h('img', { src: profileImg, style: 'width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0;' }), h('div', { className: 'tmd-preview-id-text' }, h('div', { className: 'tmd-acc-name' }, displayName || username || 'Unknown'), h('div', { className: 'tmd-acc-username' }, `@${username}`) ) ), h('div', { className: 'tmd-preview-stats' }, h('span', null, 'Media: ', h('strong', null, formatNumber(counts.total))), h('span', null, 'Followers: ', h('strong', null, formatNumber(followers))), h('span', null, 'Following: ', h('strong', null, formatNumber(following))) ), h('div', { className: 'tmd-preview-toolbar' }, h('div', { className: `tmd-filter-chip ${filters.photo ? 'active' : ''} tmd-filter-photo`, onClick: () => toggleFilter('photo'), title: 'Show photos' }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Image') } }), h('span', null, formatNumber(counts.photo)) ), h('div', { className: `tmd-filter-chip ${filters.video ? 'active' : ''} tmd-filter-video`, onClick: () => toggleFilter('video'), title: 'Show videos' }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('Video') } }), h('span', null, formatNumber(counts.video)) ), h('div', { className: `tmd-filter-chip ${filters.animated_gif ? 'active' : ''} tmd-filter-gif`, onClick: () => toggleFilter('animated_gif'), title: 'Show GIFs' }, h('span', { dangerouslySetInnerHTML: { __html: renderIcon('ImagePlay') } }), h('span', null, formatNumber(counts.animated_gif)) ) ), h('button', { className: 'tmd-preview-close', onClick: onClose, title: 'Close preview', dangerouslySetInnerHTML: { __html: renderIcon('X') } }) ), h('div', { className: 'tmd-preview-content', ref: containerRef, onTouchStart, onTouchEnd }, filtered.length === 0 ? h('div', { style: 'color:#fff; opacity:0.8; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 16px;' }, h('div', { dangerouslySetInnerHTML: { __html: renderIcon('Frown', { size: 32 }) }, style: 'opacity: 0.5;' }), h('div', { style: 'font-size: 16px;' }, 'No media for selected filters') ) : ( current?.type === 'photo' ? h('img', { className: 'tmd-preview-media', src: current.url, alt: 'photo' }) : h('video', { className: 'tmd-preview-media', src: current.url, controls: true, autoplay: false, loop: current.type === 'animated_gif', muted: current.type === 'animated_gif' }) ), filtered.length > 1 && h('div', { className: 'tmd-preview-nav prev', onClick: prev, dangerouslySetInnerHTML: { __html: renderIcon('ChevronLeft') } }), filtered.length > 1 && h('div', { className: 'tmd-preview-nav next', onClick: next, dangerouslySetInnerHTML: { __html: renderIcon('ChevronRight') } }), filtered.length > 0 && h('div', { className: 'tmd-preview-counter' }, `${Math.max(1, index + 1)}/${filtered.length}` ), current && h('div', { className: 'tmd-preview-date' }, dayjs(current.date).format('MMM DD, YYYY • HH:mm') ) ) ); } 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: renderIcon('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: renderIcon('BetweenHorizontalStart') } }), '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: renderIcon('Twitter') } }), 'Timeline' ), 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: renderIcon('HardDriveDownload') } }), 'Concurrent Downloads' ), 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: renderIcon('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('div', null, h('div', { style: 'margin-bottom: 8px;' }, '• For accounts with thousands of media: Use ', h('strong', { style: 'color: hsl(204.17deg 87.55% 52.75%);' }, 'Batch/Auto Batch'), ' mode if single fetch fails.' ), h('div', { style: 'margin-bottom: 8px;' }, '• If Default service fails: Switch to ', h('strong', { style: 'color: hsl(142.1deg 76.2% 36.3%);' }, 'Backup'), ' service.' ), h('div', null, h('strong', { style: 'color: hsl(0deg 84.2% 60.2%);' }, '• Warning:'), ' Using more than 20 concurrent downloads may cause some files to fail. Use ', h('strong', { style: 'color: hsl(142.1deg 76.2% 36.3%);' }, '20 or below'), ' for better reliability.' ) ) ) ); } function AuthTab() { const [showAuthToken, setShowAuthToken] = useState(false); const [showPatreonAuth, setShowPatreonAuth] = useState(false); const [generateStatus, setGenerateStatus] = useState('idle'); const [verifyStatus, setVerifyStatus] = useState('idle'); const handleAuthTokenChange = (e) => { state.authToken.value = e.target.value; saveSettings(); }; const handlePatreonAuthChange = (e) => { state.patreonAuth.value = e.target.value; state.isVerified.value = false; saveSettings(); }; const verifyPatreonAuth = async () => { if (!state.patreonAuth.value) { 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) { return; } if (state.patreonAuth.value === 'xbatchdemo') { 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: renderIcon('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 ? renderIcon('LockOpen') : renderIcon('Lock') } }), '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: renderIcon('Loader2') } }) : verifyStatus === 'success' ? h('span', { dangerouslySetInnerHTML: { __html: renderIcon('CircleCheckBig', { color: 'hsl(142.1deg 76.2% 36.3%)' }) } }) : verifyStatus === 'error' ? h('span', { dangerouslySetInnerHTML: { __html: renderIcon('ShieldX', { color: 'hsl(0deg 84.2% 60.2%)' }) } }) : h('span', { dangerouslySetInnerHTML: { __html: renderIcon('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 ? renderIcon('EyeOff') : renderIcon('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: renderIcon('Key') } }), '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: renderIcon('Loader2') } }) : generateStatus === 'success' ? h('span', { dangerouslySetInnerHTML: { __html: renderIcon('CircleCheckBig', { color: 'hsl(142.1deg 76.2% 36.3%)' }) } }) : generateStatus === 'error' ? h('span', { dangerouslySetInnerHTML: { __html: renderIcon('CircleX', { color: 'hsl(0deg 84.2% 60.2%)' }) } }) : h('span', { dangerouslySetInnerHTML: { __html: renderIcon('RotateCcwKey') } }), 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 ? renderIcon('EyeOff') : renderIcon('Eye') } }) ) ), h('div', { className: 'tmd-success' }, 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; cursor: pointer; transition: all 0.2s;', onClick: () => { state.patreonAuth.value = 'xbatchdemo'; state.isVerified.value = false; saveSettings(); }, onMouseEnter: (e) => { e.target.style.background = 'hsl(142.1deg 76.2% 36.3% / 0.3)'; e.target.style.transform = 'scale(1.05)'; }, onMouseLeave: (e) => { e.target.style.background = 'hsl(142.1deg 70% 29% / 0.2)'; e.target.style.transform = 'scale(1)'; }, title: 'Click to auto-fill Patreon Auth' }, 'xbatchdemo'), ' for Patreon Auth, then click ', h('strong', { style: 'color: hsl(142.1deg 76.2% 36.3%);' }, 'Verify'), ' to unlock demo access. Visit ', h('a', { href: 'https://x.com/xbatchdemo', target: '_blank', rel: 'noopener noreferrer', style: 'color: hsl(204.17deg 87.55% 52.75%); text-decoration: none;', onMouseEnter: (e) => e.target.style.textDecoration = 'underline', onMouseLeave: (e) => e.target.style.textDecoration = 'none' }, '@xbatchdemo'), ' to test.' ), h('div', { style: 'margin-bottom: 8px;' }, '• Need help getting Auth Token? ', h('a', { href: 'https://www.patreon.com/posts/how-to-obtain-127206894', target: '_blank', rel: 'noopener noreferrer', style: 'color: hsl(204.17deg 87.55% 52.75%); text-decoration: none;', onMouseEnter: (e) => e.target.style.textDecoration = 'underline', onMouseLeave: (e) => e.target.style.textDecoration = 'none' }, 'View the guide here.') ), h('div', { style: 'margin-bottom: 8px;' }, h('a', { href: 'https://www.patreon.com/exyezed/membership', target: '_blank', rel: 'noopener noreferrer', style: 'color: hsl(204.17deg 87.55% 52.75%); text-decoration: none;', onMouseEnter: (e) => e.target.style.textDecoration = 'underline', onMouseLeave: (e) => e.target.style.textDecoration = 'none' }, '• Subscribe now'), ' to receive your Patreon auth code and start downloading with ease!' ), h('div', null, '• To report bugs or request features, please contact us at ', h('a', { href: 'mailto:[email protected]', style: 'color: hsl(204.17deg 87.55% 52.75%); text-decoration: none;', onMouseEnter: (e) => e.target.style.textDecoration = 'underline', onMouseLeave: (e) => e.target.style.textDecoration = 'none' }, '[email protected]') ) ) ) ); } function createIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("width", "18"); svg.setAttribute("height", "18"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.style.cursor = "pointer"; svg.style.transition = "color 0.2s"; const paths = ["M12 15V3", "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4", "m7 10 5 5 5-5"]; paths.forEach(d => { const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", d); svg.appendChild(path); }); return svg; } function insertIcons() { document.querySelectorAll('[data-testid="UserName"]').forEach(div => { if (!div.querySelector(".dl-icon")) { const target = div.querySelector('[aria-label*="verified"]')?.closest("button")?.parentElement || div.querySelector(".css-1jxf684")?.closest("span"); if (target) { const icon = createIcon(); const wrapper = document.createElement("div"); wrapper.className = "dl-icon"; wrapper.style.cssText = ` display:inline-flex; margin-left:6px; padding:4px; background:hsl(240 3.7% 15.9%); border-radius:4px; transition:background 0.2s; `; wrapper.appendChild(icon); wrapper.onmouseenter = () => { icon.style.color = "hsl(204.17deg 87.55% 52.75%)"; wrapper.style.background = "hsl(240 3.7% 20%)"; }; wrapper.onmouseleave = () => { icon.style.color = ""; wrapper.style.background = "hsl(240 3.7% 15.9%)"; }; wrapper.onclick = (e) => { e.stopPropagation(); e.preventDefault(); let username = null; const urlMatch = window.location.pathname.match(/^\/([^\/?]+)(?:\/|\?|$)/); if (urlMatch && !['home', 'explore', 'notifications', 'messages', 'bookmarks', 'lists', 'communities', 'premium', 'verified-orgs-signup', 'settings', 'search', 'compose', 'i'].includes(urlMatch[1])) { username = urlMatch[1]; } if (!username) { const profileLink = div.closest('a[href*="/"]'); if (profileLink) { const usernameMatch = profileLink.href.match(/(?:twitter\.com|x\.com)\/([^\/\?]+)/); if (usernameMatch && !['home', 'explore', 'notifications', 'messages', 'bookmarks', 'lists', 'communities', 'premium', 'verified-orgs-signup', 'settings', 'search', 'compose', 'i'].includes(usernameMatch[1])) { username = usernameMatch[1]; } } } if (username) { state.currentUsername.value = username; } state.isModalOpen.value = true; }; target.parentNode.insertBefore(wrapper, target.nextSibling); } } }); } async function init() { await loadSettings(); const modalContainer = document.createElement('div'); modalContainer.id = 'tmd-modal-root'; document.body.appendChild(modalContainer); const renderModal = () => { render(h(Modal), modalContainer); }; effect(() => { renderModal(); }); const floatingButton = document.createElement('div'); floatingButton.id = 'tmd-floating-button'; floatingButton.style.cssText = ` position: fixed; top: 50%; left: -20px; width: 48px; height: 48px; cursor: pointer; z-index: 9998; transition: all 0.3s ease; opacity: 0.5; `; floatingButton.innerHTML = renderIcon('Twitter', { size: 48, color: 'hsl(204.17deg 87.55% 52.75%)' }) .replace('<svg', '<svg style="transform-origin: bottom center; transition: all 0.3s ease;"'); floatingButton.onmouseenter = () => { floatingButton.style.transform = 'translateX(25px) rotate(20deg)'; floatingButton.style.opacity = '0.9'; const svg = floatingButton.querySelector('svg'); if (svg) { svg.style.transform = 'scale(1.1)'; } }; floatingButton.onmouseleave = () => { floatingButton.style.transform = 'translateX(0) rotate(0)'; floatingButton.style.opacity = '0.5'; const svg = floatingButton.querySelector('svg'); if (svg) { svg.style.transform = 'scale(1)'; } }; floatingButton.onclick = (e) => { e.stopPropagation(); e.preventDefault(); let username = null; const urlMatch = window.location.pathname.match(/^\/([^\/?]+)(?:\/|\?|$)/); if (urlMatch && !['home', 'explore', 'notifications', 'messages', 'bookmarks', 'lists', 'communities', 'premium', 'verified-orgs-signup', 'settings', 'search', 'compose', 'i'].includes(urlMatch[1])) { username = urlMatch[1]; } if (username) { state.currentUsername.value = username; } state.isModalOpen.value = true; }; document.body.appendChild(floatingButton); insertIcons(); const observer = new MutationObserver(insertIcons); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址