AnnaUploader (Roblox Multi-File Uploader)

allows you to upload multiple T-Shirts/Decals easily with AnnaUploader

  1. // ==UserScript==
  2. // @name AnnaUploader (Roblox Multi-File Uploader)
  3. // @namespace https://github.com/AnnaRoblox
  4. // @version 6.1
  5. // @description allows you to upload multiple T-Shirts/Decals easily with AnnaUploader
  6. // @match https://create.roblox.com/*
  7. // @match https://www.roblox.com/users/*/profile*
  8. // @match https://www.roblox.com/communities/*
  9. // @match https://www.roblox.com/home/*
  10. // @run-at document-idle
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
  15. // @license MIT
  16. // ==/UserScript==
  17.  
  18. (function() {
  19. 'use strict';
  20.  
  21. // Constants for Roblox API and asset types
  22. const ROBLOX_UPLOAD_URL = "https://apis.roblox.com/assets/user-auth/v1/assets";
  23. const ASSET_TYPE_TSHIRT = 11;
  24. const ASSET_TYPE_DECAL = 13;
  25. const FORCED_NAME = "Uploaded Using AnnaUploader"; // Default name for assets
  26.  
  27. // Storage keys and scan interval for asset logging
  28. const STORAGE_KEY = 'annaUploaderAssetLog';
  29. const SCAN_INTERVAL_MS = 10_000;
  30.  
  31. // Script configuration variables, managed with Tampermonkey's GM_getValue/GM_setValue
  32. let USER_ID = GM_getValue('userId', null);
  33. let IS_GROUP = GM_getValue('isGroup', false);
  34. let useForcedName = false;
  35. let useMakeUnique = false;
  36. let uniqueCopies = 1;
  37. let useDownload = false;
  38. let useForceCanvasUpload = false; // New: Toggle for force canvas processing
  39.  
  40. // Mass upload mode variables
  41. let massMode = false; // True if mass upload mode is active
  42. let massQueue = []; // Array to hold files/metadata for mass upload
  43. let batchTotal = 0; // Total items to process in current batch/queue
  44. let completed = 0; // Number of items completed in current batch/queue
  45.  
  46. let csrfToken = null; // Roblox CSRF token for authenticated requests
  47. let statusEl, toggleBtn, startBtn, copiesInput, downloadBtn, forceUploadBtn; // UI elements
  48.  
  49. /**
  50. * Utility function to extract the base name of a filename (without extension).
  51. * @param {string} filename The full filename.
  52. * @returns {string} The filename without its extension.
  53. */
  54. function baseName(filename) {
  55. return filename.replace(/\.[^/.]+$/, '');
  56. }
  57.  
  58. /**
  59. * Loads the asset log from GM_getValue storage.
  60. * @returns {Object} The parsed asset log, or an empty object if parsing fails.
  61. */
  62. function loadLog() {
  63. const raw = GM_getValue(STORAGE_KEY, '{}');
  64. try { return JSON.parse(raw); }
  65. catch { return {}; }
  66. }
  67.  
  68. /**
  69. * Saves the asset log to GM_setValue storage.
  70. * @param {Object} log The asset log object to save.
  71. */
  72. function saveLog(log) {
  73. GM_setValue(STORAGE_KEY, JSON.stringify(log));
  74. }
  75.  
  76. /**
  77. * Logs an uploaded asset's details.
  78. * @param {string} id The asset ID.
  79. * @param {string|null} imageURL The URL of the asset's image.
  80. * @param {string} name The name of the asset.
  81. */
  82. function logAsset(id, imageURL, name) {
  83. const log = loadLog();
  84. log[id] = {
  85. date: new Date().toISOString(),
  86. image: imageURL || log[id]?.image || null, // Preserve existing image if new one is null
  87. name: name || log[id]?.name || '(unknown)' // Preserve existing name if new one is null
  88. };
  89. saveLog(log);
  90. console.log(`[AssetLogger] logged asset ${id} at ${log[id].date}, name: ${log[id].name}, image: ${log[id].image || "none"}`);
  91. }
  92.  
  93. /**
  94. * Scans the current page for Roblox asset links and logs them.
  95. * Runs periodically.
  96. */
  97. function scanForAssets() {
  98. console.log('[AssetLogger] scanning for assets…');
  99. document.querySelectorAll('[href]').forEach(el => {
  100. // Match asset IDs from various Roblox URLs
  101. let m = el.href.match(/(?:https?:\/\/create\.roblox\.com)?\/store\/asset\/(\d+)/)
  102. || el.href.match(/\/dashboard\/creations\/store\/(\d+)\/configure/);
  103. if (m) {
  104. const id = m[1];
  105. let image = null;
  106. const container = el.closest('*'); // Find the closest parent element to search for image/name
  107. const img = container?.querySelector('img');
  108. if (img?.src) image = img.src;
  109. let name = null;
  110. const nameEl = container?.querySelector('span.MuiTypography-root'); // Common element for asset names
  111. if (nameEl) name = nameEl.textContent.trim();
  112. logAsset(id, image, name);
  113. }
  114. });
  115. }
  116. // Start periodic scanning for new assets
  117. setInterval(scanForAssets, SCAN_INTERVAL_MS);
  118.  
  119. /**
  120. * Fetches a new CSRF token from Roblox. This token is required for upload requests.
  121. * @returns {Promise<string>} A promise that resolves with the CSRF token.
  122. * @throws {Error} If the CSRF token cannot be fetched.
  123. */
  124. async function fetchCSRFToken() {
  125. const resp = await fetch(ROBLOX_UPLOAD_URL, {
  126. method: 'POST',
  127. credentials: 'include', // Important for sending cookies
  128. headers: { 'Content-Type': 'application/json' },
  129. body: JSON.stringify({}) // Empty body to trigger a 403 and get the token
  130. });
  131. if (resp.status === 403) {
  132. const tok = resp.headers.get('x-csrf-token');
  133. if (tok) {
  134. csrfToken = tok;
  135. console.log('[CSRF] token fetched');
  136. return tok;
  137. }
  138. }
  139. throw new Error('Cannot fetch CSRF token');
  140. }
  141.  
  142. /**
  143. * Updates the status display in the UI.
  144. * Shows progress for ongoing uploads or queued count in mass mode.
  145. */
  146. function updateStatus() {
  147. if (!statusEl) return;
  148. if (massMode) {
  149. statusEl.textContent = `${massQueue.length} queued`;
  150. } else if (batchTotal > 0) {
  151. statusEl.textContent = `${completed} of ${batchTotal} processed`;
  152. } else {
  153. statusEl.textContent = ''; // Clear status if nothing is happening
  154. }
  155. }
  156.  
  157. /**
  158. * Uploads a single file to Roblox as a T-Shirt or Decal.
  159. * Includes retry logic for common errors like bad CSRF token or moderated names.
  160. * @param {File} file The file to upload.
  161. * @param {number} assetType The type of asset (ASSET_TYPE_TSHIRT or ASSET_TYPE_DECAL).
  162. * @param {number} [retries=0] Current retry count.
  163. * @param {boolean} [forceName=false] Whether to force the default name for the asset.
  164. * @returns {Promise<void>} A promise that resolves when the upload is attempted.
  165. */
  166. async function uploadFile(file, assetType, retries = 0, forceName = false) {
  167. if (!csrfToken) {
  168. try {
  169. await fetchCSRFToken();
  170. } catch (e) {
  171. console.error("[Upload] Failed to fetch initial CSRF token:", e);
  172. completed++; // Count this as a failed attempt to proceed with batch
  173. updateStatus();
  174. return;
  175. }
  176. }
  177. const displayName = forceName ? FORCED_NAME : baseName(file.name);
  178. const creator = IS_GROUP
  179. ? { groupId: USER_ID }
  180. : { userId: USER_ID };
  181.  
  182. const fd = new FormData();
  183. fd.append('fileContent', file, file.name); // The actual image file
  184. fd.append('request', JSON.stringify({ // JSON payload for the asset details
  185. displayName,
  186. description: FORCED_NAME, // Description is always the forced name
  187. assetType: assetType === ASSET_TYPE_TSHIRT ? "TShirt" : "Decal",
  188. creationContext: { creator, expectedPrice: 0 } // Price is always 0
  189. }));
  190.  
  191. try {
  192. const resp = await fetch(ROBLOX_UPLOAD_URL, {
  193. method: 'POST',
  194. credentials: 'include',
  195. headers: { 'x-csrf-token': csrfToken }, // Add CSRF token to headers
  196. body: fd
  197. });
  198. const txt = await resp.text();
  199. let json; try { json = JSON.parse(txt); } catch (e) {
  200. console.error('[Upload] Failed to parse response JSON:', e, txt);
  201. }
  202.  
  203. // Handle successful upload
  204. if (resp.ok && json?.assetId) {
  205. logAsset(json.assetId, null, displayName);
  206. completed++; // Increment on success
  207. updateStatus(); // Update status immediately
  208. return; // Exit after successful upload
  209. }
  210.  
  211. // Retry logic for common errors (no increment here, the recursive call will eventually increment)
  212. if (json?.message === 'Asset name length is invalid.' && !forceName && retries < 5) {
  213. console.warn(`[Upload] "${file.name}" name too long, retrying with default name. Retry ${retries + 1}.`);
  214. return uploadFile(file, assetType, retries + 1, true); // Retry with forced name
  215. }
  216. if (resp.status === 400 && json?.message?.includes('moderated') && retries < 5) {
  217. // If moderated, try again with default name (often resolves this)
  218. console.warn(`[Upload] "${file.name}" content moderated, retrying with default name. Retry ${retries + 1}.`);
  219. return uploadFile(file, assetType, retries + 1, true);
  220. }
  221. if (resp.status === 403 && retries < 5) {
  222. // CSRF token invalid or expired, fetch new and retry
  223. console.warn(`[Upload] "${file.name}" 403 Forbidden, fetching new CSRF and retrying. Retry ${retries + 1}.`);
  224. csrfToken = null; // Clear token to force refetch
  225. await fetchCSRFToken(); // Ensure a new token is fetched before retrying
  226. return uploadFile(file, assetType, retries + 1, forceName);
  227. }
  228.  
  229. // If we reach here, it's a final failure after retries or an unhandled HTTP error
  230. console.error(`[Upload] failed "${file.name}" [${resp.status}]`, txt);
  231. completed++; // Increment even on final failure
  232. updateStatus(); // Update status for failed upload
  233. } catch (e) {
  234. console.error(`[Upload] error during fetch for "${file.name}":`, e);
  235. completed++; // Increment on network/unhandled JS error
  236. updateStatus(); // Update status for error
  237. }
  238. }
  239.  
  240. /**
  241. * Converts a WebP image File to a PNG File.
  242. * @param {File} webpFile The WebP file to convert.
  243. * @returns {Promise<File>} A promise that resolves with the converted PNG File object.
  244. */
  245. function convertWebPToPng(webpFile) {
  246. return new Promise((resolve, reject) => {
  247. const img = new Image();
  248. img.onload = () => {
  249. const canvas = document.createElement('canvas');
  250. canvas.width = img.width;
  251. canvas.height = img.height;
  252. const ctx = canvas.getContext('2d');
  253. ctx.drawImage(img, 0, 0);
  254.  
  255. canvas.toBlob(blob => {
  256. if (blob) {
  257. const newFileName = webpFile.name.replace(/\.webp$/, '.png');
  258. resolve(new File([blob], newFileName, { type: 'image/png' }));
  259. } else {
  260. reject(new Error('Failed to convert WebP to PNG blob.'));
  261. }
  262. }, 'image/png');
  263. };
  264. img.onerror = (e) => {
  265. reject(new Error(`Failed to load image for conversion: ${e.message}`));
  266. };
  267. img.src = URL.createObjectURL(webpFile);
  268. });
  269. }
  270.  
  271. /**
  272. * Processes an image file through a canvas, re-encoding it to the target type (defaulting to PNG).
  273. * This can fix issues with malformed image data or incorrect MIME types.
  274. * @param {File} file The original image file.
  275. * @param {string} targetType The desired output MIME type (e.g., 'image/png').
  276. * @returns {Promise<File>} A promise that resolves with the new, re-encoded File object.
  277. */
  278. function processImageThroughCanvas(file, targetType = 'image/png') {
  279. return new Promise((resolve, reject) => {
  280. const img = new Image();
  281. img.onload = () => {
  282. const canvas = document.createElement('canvas');
  283. canvas.width = img.width;
  284. canvas.height = img.height;
  285. const ctx = canvas.getContext('2d');
  286. ctx.drawImage(img, 0, 0);
  287.  
  288. canvas.toBlob(blob => {
  289. if (blob) {
  290. // Preserve original base name, but update extension and type
  291. const newFileName = baseName(file.name) + (targetType === 'image/png' ? '.png' : '.jpeg'); // Simple extension logic
  292. resolve(new File([blob], newFileName, { type: targetType }));
  293. } else {
  294. reject(new Error('Failed to process image through canvas.'));
  295. }
  296. }, targetType);
  297. };
  298. img.onerror = (e) => {
  299. reject(new Error(`Failed to load image for canvas processing: ${e.message}`));
  300. };
  301. img.src = URL.createObjectURL(file);
  302. });
  303. }
  304.  
  305. /**
  306. * "Slip Mode": subtly randomizes ALL non-transparent pixels by ±1 per channel.
  307. * This creates unique images to bypass potential Roblox duplicate detection.
  308. * @param {File} file The original image file.
  309. * @param {string} origBase The base name of the original file.
  310. * @param {number} copyIndex The index of the copy (for naming).
  311. * @returns {Promise<File>} A promise that resolves with the new unique image File object.
  312. */
  313. function makeUniqueFile(file, origBase, copyIndex) {
  314. return new Promise(resolve => {
  315. const img = new Image();
  316. img.onload = () => {
  317. const canvas = document.createElement('canvas');
  318. canvas.width = img.width;
  319. canvas.height = img.height;
  320. const ctx = canvas.getContext('2d');
  321. ctx.drawImage(img, 0, 0);
  322.  
  323. const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  324. const data = imageData.data; // Pixel data: [R, G, B, A, R, G, B, A, ...]
  325. for (let i = 0; i < data.length; i += 4) {
  326. if (data[i + 3] !== 0) { // Check if alpha channel is not zero (i.e., not transparent)
  327. const delta = Math.random() < 0.5 ? -1 : 1; // Randomly add or subtract 1
  328. data[i] = Math.min(255, Math.max(0, data[i] + delta)); // Red
  329. data[i+1] = Math.min(255, Math.max(0, data[i+1] + delta)); // Green
  330. data[i+2] = Math.min(255, Math.max(0, data[i+2] + delta)); // Blue
  331. }
  332. }
  333. ctx.putImageData(imageData, 0, 0); // Put modified data back to canvas
  334.  
  335. canvas.toBlob(blob => {
  336. const ext = 'png'; // Always output PNG after processing, especially if converted from WebP
  337. const newName = `${origBase}_${copyIndex}.${ext}`; // Create new name with index
  338. resolve(new File([blob], newName, { type: 'image/png' })); // Resolve with new File object as PNG
  339. }, 'image/png'); // Always convert to PNG
  340. };
  341. img.src = URL.createObjectURL(file); // Load image from file blob URL
  342. });
  343. }
  344.  
  345. /**
  346. * Handles file selection from input or paste events.
  347. * Depending on `massMode`, it either queues files or initiates immediate uploads.
  348. * @param {FileList|File[]} files The list of files selected.
  349. * @param {number|null} assetType The asset type (TSHIRT, DECAL, or null for 'both').
  350. * @param {boolean} [both=false] If true, upload as both T-Shirt and Decal.
  351. */
  352. async function handleFileSelect(files, assetType, both = false) {
  353. if (!files?.length) return;
  354.  
  355. const downloadsMap = {};
  356. const copies = useMakeUnique ? uniqueCopies : 1;
  357.  
  358. if (massMode) {
  359. // In mass mode, add files to the queue after processing
  360. displayMessage('Processing files to add to queue...', 'info');
  361. const processingTasks = [];
  362. for (const original of files) {
  363. let fileToProcess = original;
  364.  
  365. // 1. WebP Conversion (always happens first if needed)
  366. if (original.type === 'image/webp') {
  367. displayMessage(`Converting ${original.name} from WebP to PNG...`, 'info');
  368. try {
  369. fileToProcess = await convertWebPToPng(original);
  370. displayMessage(`${original.name} converted to PNG.`, 'success');
  371. } catch (error) {
  372. displayMessage(`Failed to convert ${original.name}: ${error.message}`, 'error');
  373. console.error(`[Conversion] Failed to convert ${original.name}:`, error);
  374. continue; // Skip this file if conversion fails
  375. }
  376. }
  377.  
  378. // 2. Force Canvas Upload (if enabled AND not already handled by makeUniqueFile)
  379. // If useMakeUnique is true, makeUniqueFile already processes through canvas, so no need to double process.
  380. let fileAfterCanvasProcessing = fileToProcess;
  381. if (useForceCanvasUpload && !useMakeUnique) {
  382. displayMessage(`Processing ${fileToProcess.name} through canvas...`, 'info');
  383. try {
  384. fileAfterCanvasProcessing = await processImageThroughCanvas(fileToProcess);
  385. displayMessage(`${fileToProcess.name} processed through canvas.`, 'success');
  386. } catch (error) {
  387. displayMessage(`Failed to process ${fileToProcess.name} through canvas: ${error.message}`, 'error');
  388. console.error(`[Canvas Process] Failed to process ${fileToProcess.name}:`, error);
  389. continue; // Skip this file if canvas processing fails
  390. }
  391. }
  392.  
  393. const origBase = baseName(fileAfterCanvasProcessing.name); // Use the name from the potentially canvas-processed file
  394. for (let i = 1; i <= copies; i++) {
  395. processingTasks.push(
  396. (async () => {
  397. const fileForQueue = useMakeUnique
  398. ? await makeUniqueFile(fileAfterCanvasProcessing, origBase, i)
  399. : fileAfterCanvasProcessing; // Use the file after potential canvas processing
  400.  
  401. if (both) {
  402. massQueue.push({ f: fileForQueue, type: ASSET_TYPE_TSHIRT, forceName: useForcedName });
  403. massQueue.push({ f: fileForQueue, type: ASSET_TYPE_DECAL, forceName: useForcedName });
  404. } else {
  405. massQueue.push({ f: fileForQueue, type: assetType, forceName: useForcedName });
  406. }
  407. })()
  408. );
  409. }
  410. }
  411. await Promise.all(processingTasks); // Wait for all files to be processed and queued
  412. displayMessage(`${processingTasks.length} files added to queue!`, 'success');
  413. updateStatus(); // Update status to show queued items
  414. } else {
  415. // Not in mass mode, proceed with immediate upload
  416. const totalFilesToUpload = files.length * (both ? 2 : 1) * copies;
  417. batchTotal = totalFilesToUpload; // Set total for immediate batch
  418. completed = 0;
  419. updateStatus();
  420. displayMessage(`Starting upload of ${batchTotal} files...`, 'info');
  421.  
  422. const uploadPromises = []; // Array to hold upload promises
  423.  
  424. for (const original of files) {
  425. let fileToProcess = original;
  426.  
  427. // 1. WebP Conversion (always happens first if needed)
  428. if (original.type === 'image/webp') {
  429. displayMessage(`Converting ${original.name} from WebP to PNG...`, 'info');
  430. try {
  431. fileToProcess = await convertWebPToPng(original);
  432. displayMessage(`${original.name} converted to PNG.`, 'success');
  433. } catch (error) {
  434. displayMessage(`Failed to convert ${original.name}: ${error.message}`, 'error');
  435. console.error(`[Conversion] Failed to convert ${original.name}:`, error);
  436. continue; // Skip this file if conversion fails
  437. }
  438. }
  439.  
  440. // 2. Force Canvas Upload (if enabled AND not already handled by makeUniqueFile)
  441. let fileAfterCanvasProcessing = fileToProcess;
  442. if (useForceCanvasUpload && !useMakeUnique) {
  443. displayMessage(`Processing ${fileToProcess.name} through canvas...`, 'info');
  444. try {
  445. fileAfterCanvasProcessing = await processImageThroughCanvas(fileToProcess);
  446. displayMessage(`${fileToProcess.name} processed through canvas.`, 'success');
  447. } catch (error) {
  448. displayMessage(`Failed to process ${fileToProcess.name} through canvas: ${error.message}`, 'error');
  449. console.error(`[Canvas Process] Failed to process ${fileToProcess.name}:`, error);
  450. continue; // Skip this file if canvas processing fails
  451. }
  452. }
  453.  
  454. const origBase = baseName(fileAfterCanvasProcessing.name); // Use the name from the potentially canvas-processed file
  455. downloadsMap[origBase] = []; // Initialize for potential downloads
  456.  
  457. for (let i = 1; i <= copies; i++) {
  458. const fileToUpload = useMakeUnique
  459. ? await makeUniqueFile(fileAfterCanvasProcessing, origBase, i)
  460. : fileAfterCanvasProcessing; // Get the processed file
  461.  
  462. if (useMakeUnique && useDownload) downloadsMap[origBase].push(fileToUpload);
  463. if (both) {
  464. uploadPromises.push(uploadFile(fileToUpload, ASSET_TYPE_TSHIRT, 0, useForcedName));
  465. uploadPromises.push(uploadFile(fileToUpload, ASSET_TYPE_DECAL, 0, useForcedName));
  466. } else {
  467. uploadPromises.push(uploadFile(fileToUpload, assetType, 0, useForcedName));
  468. }
  469. }
  470. }
  471.  
  472. // Wait for all immediate uploads to complete
  473. Promise.all(uploadPromises).then(() => {
  474. console.log('[Uploader] batch done');
  475. scanForAssets(); // Rescan for newly uploaded assets
  476. displayMessage('Immediate upload batch complete!', 'success');
  477. // Handle downloading of unique images if enabled
  478. if (useMakeUnique && useDownload) {
  479. for (const [origBase, fileList] of Object.entries(downloadsMap)) {
  480. if (!fileList.length) continue;
  481. const zip = new JSZip();
  482. fileList.forEach(f => zip.file(f.name, f));
  483. zip.generateAsync({ type: 'blob' }).then(blob => {
  484. const url = URL.createObjectURL(blob);
  485. const a = document.createElement('a');
  486. a.href = url;
  487. a.download = `${origBase}.zip`;
  488. document.body.appendChild(a);
  489. a.click();
  490. document.body.removeChild(a);
  491. URL.revokeObjectURL(url);
  492. });
  493. }
  494. }
  495. }).catch(error => {
  496. console.error("Immediate upload batch encountered an error:", error);
  497. displayMessage('Immediate upload batch finished with errors. Check console.', 'error');
  498. });
  499. }
  500. }
  501.  
  502. /**
  503. * Starts the mass upload process for all files currently in the queue.
  504. */
  505. function startMassUpload() {
  506. if (!massQueue.length) {
  507. displayMessage('Nothing queued for mass upload!', 'info');
  508. return;
  509. }
  510.  
  511. batchTotal = massQueue.length; // Set total for this mass upload batch
  512. completed = 0; // Reset completed counter for the new batch
  513. updateStatus();
  514. displayMessage(`Starting mass upload of ${batchTotal} files...`, 'info');
  515.  
  516. // Create an array of promises for each upload task
  517. const tasks = massQueue.map(item => uploadFile(item.f, item.type, 0, item.forceName));
  518. massQueue = []; // Clear the queue once uploads begin
  519.  
  520. // Wait for all uploads in the mass batch to complete
  521. Promise.all(tasks).then(() => {
  522. displayMessage('Mass upload complete!', 'success');
  523. // Reset mass mode and UI elements after completion
  524. massMode = false;
  525. toggleBtn.textContent = 'Enable Mass Upload';
  526. startBtn.style.display = 'none';
  527. scanForAssets(); // Rescan for all newly uploaded assets
  528. batchTotal = completed = 0; // Reset progress counters for next operation
  529. updateStatus(); // Final status update
  530. }).catch(error => {
  531. console.error("Mass upload encountered an error:", error);
  532. displayMessage('Mass upload finished with errors. Check console.', 'error');
  533. massMode = false;
  534. toggleBtn.textContent = 'Enable Mass Upload';
  535. startBtn.style.display = 'none';
  536. batchTotal = completed = 0;
  537. updateStatus();
  538. });
  539. }
  540.  
  541. /**
  542. * Displays a custom modal message instead of `alert()`.
  543. * @param {string} message The message to display.
  544. * @param {'info'|'success'|'error'} [type='info'] The type of message for styling.
  545. */
  546. function displayMessage(message, type = 'info') {
  547. const modal = document.createElement('div');
  548. Object.assign(modal.style, {
  549. position: 'fixed',
  550. top: '50%',
  551. left: '50%',
  552. transform: 'translate(-50%, -50%)',
  553. padding: '20px',
  554. background: '#333',
  555. color: '#fff',
  556. borderRadius: '8px',
  557. boxShadow: '0 4px 10px rgba(0,0,0,0.5)',
  558. zIndex: '10001',
  559. fontFamily: 'Inter, Arial, sans-serif',
  560. textAlign: 'center',
  561. minWidth: '250px',
  562. transition: 'opacity 0.3s ease-in-out',
  563. opacity: '0' // Start hidden for transition
  564. });
  565.  
  566. if (type === 'success') {
  567. modal.style.background = '#4CAF50'; // Green
  568. } else if (type === 'error') {
  569. modal.style.background = '#f44336'; // Red
  570. }
  571.  
  572. modal.textContent = message;
  573.  
  574. document.body.appendChild(modal);
  575.  
  576. // Fade in
  577. setTimeout(() => modal.style.opacity = '1', 10);
  578.  
  579. // Fade out and remove after a delay
  580. setTimeout(() => {
  581. modal.style.opacity = '0';
  582. modal.addEventListener('transitionend', () => modal.remove());
  583. }, 3000);
  584. }
  585.  
  586. /**
  587. * Displays a custom modal prompt instead of `prompt()`.
  588. * @param {string} message The message to display in the prompt.
  589. * @param {string} [defaultValue=''] The default value for the input field.
  590. * @returns {Promise<string|null>} A promise that resolves with the input value or null if canceled.
  591. */
  592. function customPrompt(message, defaultValue = '') {
  593. return new Promise(resolve => {
  594. const modal = document.createElement('div');
  595. Object.assign(modal.style, {
  596. position: 'fixed',
  597. top: '50%',
  598. left: '50%',
  599. transform: 'translate(-50%, -50%)',
  600. padding: '20px',
  601. background: '#222',
  602. color: '#fff',
  603. borderRadius: '8px',
  604. boxShadow: '0 6px 15px rgba(0,0,0,0.4)',
  605. zIndex: '10002', // Higher z-index than message modal
  606. fontFamily: 'Inter, Arial, sans-serif',
  607. textAlign: 'center',
  608. minWidth: '300px',
  609. display: 'flex',
  610. flexDirection: 'column',
  611. gap: '15px',
  612. transition: 'opacity 0.3s ease-in-out',
  613. opacity: '0'
  614. });
  615.  
  616. const textDiv = document.createElement('div');
  617. textDiv.textContent = message;
  618. textDiv.style.fontSize = '16px';
  619. modal.appendChild(textDiv);
  620.  
  621. const input = document.createElement('input');
  622. input.type = 'text';
  623. input.value = defaultValue;
  624. Object.assign(input.style, {
  625. padding: '10px',
  626. borderRadius: '5px',
  627. border: '1px solid #555',
  628. background: '#333',
  629. color: '#fff',
  630. fontSize: '14px',
  631. outline: 'none'
  632. });
  633. modal.appendChild(input);
  634.  
  635. const buttonContainer = document.createElement('div');
  636. Object.assign(buttonContainer.style, {
  637. display: 'flex',
  638. justifyContent: 'space-around',
  639. gap: '10px',
  640. marginTop: '10px'
  641. });
  642.  
  643. const okBtn = document.createElement('button');
  644. okBtn.textContent = 'OK';
  645. Object.assign(okBtn.style, {
  646. padding: '10px 20px',
  647. cursor: 'pointer',
  648. color: '#fff',
  649. background: '#007bff',
  650. border: 'none',
  651. borderRadius: '5px',
  652. fontSize: '14px',
  653. flexGrow: '1'
  654. });
  655. okBtn.onmouseover = () => okBtn.style.background = '#0056b3';
  656. okBtn.onmouseout = () => okBtn.style.background = '#007bff';
  657. okBtn.onclick = () => {
  658. modal.style.opacity = '0';
  659. modal.addEventListener('transitionend', () => modal.remove());
  660. resolve(input.value);
  661. };
  662. buttonContainer.appendChild(okBtn);
  663.  
  664. const cancelBtn = document.createElement('button');
  665. cancelBtn.textContent = 'Cancel';
  666. Object.assign(cancelBtn.style, {
  667. padding: '10px 20px',
  668. cursor: 'pointer',
  669. color: '#fff',
  670. background: '#6c757d',
  671. border: 'none',
  672. borderRadius: '5px',
  673. fontSize: '14px',
  674. flexGrow: '1'
  675. });
  676. cancelBtn.onmouseover = () => cancelBtn.style.background = '#5a6268';
  677. cancelBtn.onmouseout = () => cancelBtn.style.background = '#6c757d';
  678. cancelBtn.onclick = () => {
  679. modal.style.opacity = '0';
  680. modal.addEventListener('transitionend', () => modal.remove());
  681. resolve(null);
  682. };
  683. buttonContainer.appendChild(cancelBtn);
  684.  
  685. modal.appendChild(buttonContainer);
  686. document.body.appendChild(modal);
  687.  
  688. // Fade in
  689. setTimeout(() => modal.style.opacity = '1', 10);
  690.  
  691. input.focus();
  692. input.addEventListener('keypress', (e) => {
  693. if (e.key === 'Enter') {
  694. okBtn.click();
  695. }
  696. });
  697. });
  698. }
  699.  
  700.  
  701. /**
  702. * Creates and injects the AnnaUploader UI panel into the page.
  703. */
  704. function createUI() {
  705. const c = document.createElement('div');
  706. Object.assign(c.style, {
  707. position: 'fixed',
  708. top: '10px',
  709. right: '10px',
  710. width: '260px',
  711. background: '#1a1a1a', // Darker background
  712. border: '2px solid #333', // Subtle border
  713. color: '#e0e0e0', // Lighter text color
  714. padding: '15px',
  715. zIndex: 10000,
  716. borderRadius: '8px',
  717. boxShadow: '0 4px 12px rgba(0,0,0,0.4)', // Stronger shadow
  718. display: 'flex',
  719. flexDirection: 'column',
  720. gap: '10px', // More spacing
  721. fontFamily: 'Inter, Arial, sans-serif' // Modern font
  722. });
  723.  
  724. // Helper to create styled buttons
  725. function btn(text, fn) {
  726. const b = document.createElement('button');
  727. b.textContent = text;
  728. Object.assign(b.style, {
  729. padding: '10px',
  730. cursor: 'pointer',
  731. color: '#fff',
  732. background: '#3a3a3a', // Darker button background
  733. border: '1px solid #555',
  734. borderRadius: '5px', // Slightly more rounded
  735. transition: 'background 0.2s ease-in-out',
  736. fontSize: '14px'
  737. });
  738. b.onmouseover = () => b.style.background = '#505050'; // Hover effect
  739. b.onmouseout = () => b.style.background = '#3a3a3a';
  740. b.onclick = fn;
  741. return b;
  742. }
  743.  
  744. // Close button for the UI panel
  745. const close = btn('×', () => c.remove());
  746. Object.assign(close.style, {
  747. position: 'absolute',
  748. top: '5px',
  749. right: '8px',
  750. background: 'transparent',
  751. border: 'none',
  752. fontSize: '18px',
  753. color: '#e0e0e0',
  754. fontWeight: 'bold',
  755. transition: 'color 0.2s',
  756. padding: '5px 8px' // Make it easier to click
  757. });
  758. close.onmouseover = () => close.style.color = '#fff';
  759. close.onmouseout = () => close.style.color = '#e0e0e0';
  760. close.title = 'Close AnnaUploader';
  761. c.appendChild(close);
  762.  
  763. const title = document.createElement('h3');
  764. title.textContent = 'AnnaUploader';
  765. title.style.margin = '0 0 10px 0'; // More margin below title
  766. title.style.color = '#4af'; // Accent color for title
  767. title.style.textAlign = 'center';
  768. c.appendChild(title);
  769.  
  770. // Upload T-Shirts button
  771. c.appendChild(btn('Upload T-Shirts', () => {
  772. const i = document.createElement('input');
  773. i.type = 'file'; i.accept = 'image/*'; i.multiple = true;
  774. i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_TSHIRT);
  775. i.click();
  776. }));
  777. // Upload Decals button
  778. c.appendChild(btn('Upload Decals', () => {
  779. const i = document.createElement('input');
  780. i.type = 'file'; i.accept = 'image/*'; i.multiple = true;
  781. i.onchange = e => handleFileSelect(e.target.files, ASSET_TYPE_DECAL);
  782. i.click();
  783. }));
  784. // Upload Both button
  785. c.appendChild(btn('Upload Both', () => {
  786. const i = document.createElement('input');
  787. i.type = 'file'; i.accept = 'image/*'; i.multiple = true;
  788. i.onchange = e => handleFileSelect(e.target.files, null, true); // null means 'both'
  789. i.click();
  790. }));
  791.  
  792. // Mass Upload toggle button
  793. toggleBtn = btn('Enable Mass Upload', () => {
  794. massMode = !massMode;
  795. toggleBtn.textContent = massMode ? 'Disable Mass Upload' : 'Enable Mass Upload';
  796. startBtn.style.display = massMode ? 'block' : 'none'; // Show/hide start button
  797. massQueue = []; // Clear queue when toggling mode
  798. batchTotal = completed = 0; // Reset progress
  799. updateStatus(); // Update status display
  800. displayMessage(`Mass Upload Mode: ${massMode ? 'Enabled' : 'Disabled'}`, 'info');
  801. });
  802. c.appendChild(toggleBtn);
  803.  
  804. // Start Mass Upload button (initially hidden)
  805. startBtn = btn('Start Mass Upload', startMassUpload);
  806. startBtn.style.display = 'none';
  807. Object.assign(startBtn.style, {
  808. background: '#28a745', // Green for start
  809. border: '1px solid #218838'
  810. });
  811. startBtn.onmouseover = () => startBtn.style.background = '#218838';
  812. startBtn.onmouseout = () => startBtn.style.background = '#28a745';
  813. c.appendChild(startBtn);
  814.  
  815. // Use default Name toggle
  816. const nameBtn = btn(`Use default Name: ${useForcedName ? 'On' : 'Off'}`, () => {
  817. useForcedName = !useForcedName;
  818. nameBtn.textContent = `Use default Name: ${useForcedName ? 'On' : 'Off'}`;
  819. });
  820. c.appendChild(nameBtn);
  821.  
  822. // Slip Mode toggle
  823. const slipBtn = btn(`Slip Mode: ${useMakeUnique ? 'On' : 'Off'}`, () => {
  824. useMakeUnique = !useMakeUnique;
  825. slipBtn.textContent = `Slip Mode: ${useMakeUnique ? 'On' : 'Off'}`;
  826. copiesInput.style.display = useMakeUnique ? 'block' : 'none'; // Show/hide copies input
  827. downloadBtn.style.display = useMakeUnique ? 'block' : 'none'; // Show/hide download button
  828. if (!useMakeUnique) { // If turning Slip Mode off, also turn off download
  829. useDownload = false;
  830. downloadBtn.textContent = 'Download Images: Off';
  831. }
  832. });
  833. c.appendChild(slipBtn);
  834.  
  835. // Copies input for Slip Mode
  836. copiesInput = document.createElement('input');
  837. copiesInput.type = 'number'; copiesInput.min = '1'; copiesInput.value = uniqueCopies;
  838. Object.assign(copiesInput.style, {
  839. width: '100%',
  840. boxSizing: 'border-box',
  841. display: 'none', // Initially hidden
  842. padding: '8px',
  843. borderRadius: '4px',
  844. border: '1px solid #555',
  845. background: '#333',
  846. color: '#fff',
  847. textAlign: 'center'
  848. });
  849. copiesInput.onchange = e => {
  850. const v = parseInt(e.target.value, 10);
  851. if (v > 0) uniqueCopies = v;
  852. else e.target.value = uniqueCopies; // Revert to valid value if invalid input
  853. };
  854. c.appendChild(copiesInput);
  855.  
  856. // Download Images toggle for Slip Mode
  857. downloadBtn = btn(`Download Images: ${useDownload ? 'On' : 'Off'}`, () => {
  858. useDownload = !useDownload;
  859. downloadBtn.textContent = `Download Images: ${useDownload ? 'On' : 'Off'}`;
  860. });
  861. downloadBtn.style.display = 'none'; // Initially hidden
  862. c.appendChild(downloadBtn);
  863.  
  864. // New: Force Upload (through Canvas) toggle
  865. forceUploadBtn = btn(`Force Upload: ${useForceCanvasUpload ? 'On' : 'Off'}`, () => {
  866. useForceCanvasUpload = !useForceCanvasUpload;
  867. forceUploadBtn.textContent = `Force Upload: ${useForceCanvasUpload ? 'On' : 'Off'}`;
  868. displayMessage(`Force Upload Mode: ${useForceCanvasUpload ? 'Enabled' : 'Disabled'}`, 'info');
  869. });
  870. c.appendChild(forceUploadBtn);
  871.  
  872. // Change ID button
  873. c.appendChild(btn('Change ID', async () => {
  874. const inp = await customPrompt("Enter your Roblox User ID/URL or Group URL:", USER_ID || '');
  875. if (inp === null) return; // User cancelled
  876. let id, isGrp = false;
  877. const um = inp.match(/users\/(\d+)/);
  878. const gm = inp.match(/communities\/(\d+)/);
  879. if (um) {
  880. id = um[1];
  881. } else if (gm) {
  882. id = gm[1];
  883. isGrp = true;
  884. } else {
  885. id = inp.trim();
  886. if (isNaN(id) || id === '') { // Check for empty string after trim as well
  887. displayMessage('Invalid input. Please enter a number or a valid URL.', 'error');
  888. return;
  889. }
  890. }
  891. USER_ID = Number(id);
  892. IS_GROUP = isGrp;
  893. GM_setValue('userId', USER_ID);
  894. GM_setValue('isGroup', IS_GROUP);
  895. displayMessage(`Set to ${isGrp ? 'Group' : 'User'} ID: ${USER_ID}`, 'success');
  896. }));
  897.  
  898. // "Use This Profile as ID" button (contextual)
  899. const pm = window.location.pathname.match(/^\/users\/(\d+)\/profile/);
  900. if (pm) {
  901. c.appendChild(btn('Use This Profile as ID', () => {
  902. USER_ID = Number(pm[1]);
  903. IS_GROUP = false;
  904. GM_setValue('userId', USER_ID);
  905. GM_setValue('isGroup', IS_GROUP);
  906. displayMessage(`User ID set to ${USER_ID}`, 'success');
  907. }));
  908. }
  909.  
  910. // "Use This Group as ID" button (contextual)
  911. const gm = window.location.pathname.match(/^\/communities\/(\d+)/);
  912. if (gm) {
  913. c.appendChild(btn('Use This Group as ID', () => {
  914. USER_ID = Number(gm[1]);
  915. IS_GROUP = true;
  916. GM_setValue('userId', USER_ID);
  917. GM_setValue('isGroup', IS_GROUP);
  918. displayMessage(`Group ID set to ${USER_ID}`, 'success');
  919. }));
  920. }
  921.  
  922. // Show Logged Assets button
  923. c.appendChild(btn('Show Logged Assets', () => {
  924. const log = loadLog();
  925. const entries = Object.entries(log);
  926. const w = window.open('', '_blank'); // Open a new blank window
  927. w.document.write(`<!DOCTYPE html>
  928. <html><head><meta charset="utf-8"><title>Logged Assets</title>
  929. <style>
  930. body { font-family:Arial; padding:20px; background:#121212; color:#f0f0f0; }
  931. h1 { margin-bottom:15px; color:#4af; }
  932. ul { list-style:none; padding:0; }
  933. li { margin-bottom:15px; padding:10px; background:#1e1e1e; border-radius:8px; display:flex; flex-direction:column; gap:8px;}
  934. img { max-height:60px; border:1px solid #444; border-radius:4px; object-fit:contain; background:#333; }
  935. .asset-info { display:flex;align-items:center;gap:15px; }
  936. a { color:#7cf; text-decoration:none; font-weight:bold; }
  937. a:hover { text-decoration:underline; }
  938. .asset-name { font-size:0.9em; color:#bbb; margin-left: auto; text-align: right; }
  939. button { margin-bottom:20px; color:#fff; background:#3a3a3a; border:1px solid #555; padding:8px 15px; border-radius:5px; cursor:pointer; }
  940. button:hover { background:#505050; }
  941. </style></head><body>
  942. <button onclick="document.body.style.background=(document.body.style.background==='#121212'?'#f0f0f0':'#121212');document.body.style.color=(document.body.style.color==='#f0f0f0'?'#121212':'#f0f0f0');document.querySelectorAll('li').forEach(li=>li.style.background=(document.body.style.background==='#121212'?'#1e1e1e':'#e0e0e0'));document.querySelectorAll('a').forEach(a=>a.style.color=(document.body.style.background==='#121212'?'#7cf':'#007bff'));document.querySelectorAll('img').forEach(i=>i.style.border=(document.body.style.background==='#121212'?'1px solid #444':'1px solid #ccc'));">Toggle Theme</button>
  943. <h1>Logged Assets</h1>
  944. ${ entries.length ? `<ul>${entries.map(([id,entry])=>
  945. `<li>
  946. <div class="asset-info">
  947. ${ entry.image ? `<img src="${entry.image}" alt="Asset thumbnail">` : `<span style="color:#888;">(no image)</span>` }
  948. <a href="https://create.roblox.com/store/asset/${id}" target="_blank">${id}</a>
  949. <span style="font-size:0.85em; color:#999;">${new Date(entry.date).toLocaleString()}</span>
  950. </div>
  951. <div class="asset-name">${entry.name}</div>
  952. </li>`).join('') }</ul>` : `<p style="color:#888;"><em>No assets logged yet.</em></p>`}
  953. </body></html>`);
  954. w.document.close(); // Close the document stream to ensure content is rendered
  955. }));
  956.  
  957. const hint = document.createElement('div');
  958. hint.textContent = 'Paste images (Ctrl+V) to queue/upload';
  959. hint.style.fontSize = '12px'; hint.style.color = '#aaa';
  960. hint.style.textAlign = 'center';
  961. hint.style.marginTop = '5px';
  962. c.appendChild(hint);
  963.  
  964. // Status element at the bottom
  965. statusEl = document.createElement('div');
  966. statusEl.style.fontSize = '13px'; statusEl.style.color = '#fff';
  967. statusEl.style.textAlign = 'center';
  968. statusEl.style.paddingTop = '10px';
  969. statusEl.style.borderTop = '1px solid #333';
  970. c.appendChild(statusEl);
  971.  
  972. document.body.appendChild(c);
  973. }
  974.  
  975. /**
  976. * Handles paste events, attempting to extract image data and process it for upload.
  977. * @param {ClipboardEvent} e The paste event object.
  978. */
  979. async function handlePaste(e) {
  980. const items = e.clipboardData?.items;
  981. if (!items) return;
  982.  
  983. for (const it of items) {
  984. if (it.type.startsWith('image')) {
  985. e.preventDefault(); // Prevent default paste behavior
  986. const blob = it.getAsFile();
  987. const ts = new Date().toISOString().replace(/[^a-z0-9]/gi,'_'); // Timestamp for default name
  988.  
  989. const pastedName = await customPrompt('Enter a name for the image (no extension):', `pasted_${ts}`);
  990. if (pastedName === null) return; // User cancelled
  991. let name = pastedName.trim() || `pasted_${ts}`;
  992. let filename = name.endsWith('.png') ? name : `${name}.png`; // Default to PNG
  993.  
  994. let fileToProcess = new File([blob], filename, {type: blob.type});
  995.  
  996. // 1. WebP Conversion (always happens first if needed)
  997. if (blob.type === 'image/webp') {
  998. displayMessage(`Converting pasted WebP image to PNG...`, 'info');
  999. try {
  1000. fileToProcess = await convertWebPToPng(fileToProcess);
  1001. // Update filename and type to reflect PNG
  1002. name = baseName(fileToProcess.name); // Get base name from the new PNG file
  1003. filename = fileToProcess.name; // Use the full name of the new PNG file
  1004. displayMessage(`Pasted WebP converted to PNG.`, 'success');
  1005. } catch (error) {
  1006. displayMessage(`Failed to convert pasted WebP: ${error.message}`, 'error');
  1007. console.error(`[Conversion] Failed to convert pasted WebP:`, error);
  1008. return; // Stop processing this paste if conversion fails
  1009. }
  1010. }
  1011.  
  1012. // 2. Force Canvas Upload (if enabled AND not already handled by makeUniqueFile)
  1013. // For pasted images, makeUniqueFile is not directly called here, so always apply if force upload is on.
  1014. if (useForceCanvasUpload) {
  1015. displayMessage(`Processing pasted image through canvas...`, 'info');
  1016. try {
  1017. fileToProcess = await processImageThroughCanvas(fileToProcess);
  1018. // Update filename and type to reflect PNG after canvas processing
  1019. name = baseName(fileToProcess.name);
  1020. filename = fileToProcess.name;
  1021. displayMessage(`Pasted image processed through canvas.`, 'success');
  1022. } catch (error) {
  1023. displayMessage(`Failed to process pasted image through canvas: ${error.message}`, 'error');
  1024. console.error(`[Canvas Process] Failed to process pasted image:`, error);
  1025. return; // Stop processing this paste if canvas processing fails
  1026. }
  1027. }
  1028.  
  1029.  
  1030. const typeChoice = await customPrompt('Upload as T=T-Shirt, D=Decal, or C=Cancel?', 'D');
  1031. if (!typeChoice) return; // User cancelled
  1032. const t = typeChoice.trim().toUpperCase();
  1033. const type = t === 'T' ? ASSET_TYPE_TSHIRT : t === 'D' ? ASSET_TYPE_DECAL : null;
  1034.  
  1035. if (!type) {
  1036. displayMessage('Invalid asset type selected. Please choose T or D.', 'error');
  1037. return;
  1038. }
  1039.  
  1040. // Process the pasted file like any other selected file
  1041. handleFileSelect([fileToProcess], type);
  1042. break; // Process only the first image found
  1043. }
  1044. }
  1045. }
  1046.  
  1047. // Initialize the UI and event listeners when the window loads
  1048. window.addEventListener('load', () => {
  1049. createUI();
  1050. document.addEventListener('paste', handlePaste);
  1051. scanForAssets(); // Initial scan
  1052. console.log('[AnnaUploader] initialized; asset scan every ' + (SCAN_INTERVAL_MS/1000) + 's');
  1053. });
  1054.  
  1055. })();