1
0

index.js 94 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437
  1. // Global variables
  2. let allPatterns = [];
  3. let allPatternsWithMetadata = []; // Enhanced pattern data with metadata
  4. let currentSort = { field: 'name', direction: 'asc' };
  5. let currentFilters = { category: 'all' };
  6. // AbortController for cancelling in-flight requests when navigating away
  7. let metadataAbortController = null;
  8. // Helper function to normalize file paths for cross-platform compatibility
  9. function normalizeFilePath(filePath) {
  10. if (!filePath) return '';
  11. // First normalize path separators
  12. let normalized = filePath.replace(/\\/g, '/');
  13. // Remove only the patterns directory prefix, not patterns within the path
  14. if (normalized.startsWith('./patterns/')) {
  15. normalized = normalized.substring(11);
  16. } else if (normalized.startsWith('patterns/')) {
  17. normalized = normalized.substring(9);
  18. }
  19. return normalized;
  20. }
  21. let selectedPattern = null;
  22. let previewObserver = null;
  23. let currentBatch = 0;
  24. const BATCH_SIZE = 40; // Increased batch size for better performance
  25. let previewCache = new Map(); // Simple in-memory cache for preview data
  26. let imageCache = new Map(); // Cache for preloaded images
  27. // Global variables for lazy loading
  28. let pendingPatterns = new Map(); // pattern -> element mapping
  29. let batchTimeout = null;
  30. const INITIAL_BATCH_SIZE = 12; // Smaller initial batch for faster first load
  31. const LAZY_BATCH_SIZE = 5; // Reduced batch size for smoother loading
  32. const MAX_RETRIES = 3; // Maximum number of retries for failed loads
  33. const RETRY_DELAY = 1000; // Delay between retries in ms
  34. // Shared caching for patterns list (persistent across sessions)
  35. const PATTERNS_CACHE_KEY = 'dune_weaver_patterns_cache';
  36. // IndexedDB cache for preview images with size management (shared with playlists page)
  37. const PREVIEW_CACHE_DB_NAME = 'dune_weaver_previews';
  38. const PREVIEW_CACHE_DB_VERSION = 1;
  39. const PREVIEW_CACHE_STORE_NAME = 'previews';
  40. const MAX_CACHE_SIZE_MB = 200;
  41. const MAX_CACHE_SIZE_BYTES = MAX_CACHE_SIZE_MB * 1024 * 1024;
  42. let previewCacheDB = null;
  43. // Define constants for log message types
  44. const LOG_TYPE = {
  45. SUCCESS: 'success',
  46. WARNING: 'warning',
  47. ERROR: 'error',
  48. INFO: 'info',
  49. DEBUG: 'debug'
  50. };
  51. // Cache progress storage keys
  52. const CACHE_PROGRESS_KEY = 'dune_weaver_cache_progress';
  53. const CACHE_TIMESTAMP_KEY = 'dune_weaver_cache_timestamp';
  54. const CACHE_PROGRESS_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
  55. // Animated Preview Variables
  56. let animatedPreviewData = null;
  57. let animationFrameId = null;
  58. let isPlaying = false;
  59. let currentProgress = 0;
  60. let animationSpeed = 1;
  61. let lastTimestamp = 0;
  62. // Function to show status message
  63. function showStatusMessage(message, type = 'success') {
  64. const statusContainer = document.getElementById('status-message-container');
  65. const statusMessage = document.getElementById('status-message');
  66. if (!statusContainer || !statusMessage) return;
  67. // Set message and color based on type
  68. statusMessage.textContent = message;
  69. statusMessage.className = `text-base font-semibold opacity-0 transform -translate-y-2 transition-all duration-300 ease-in-out px-4 py-2 rounded-lg shadow-lg ${
  70. type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' :
  71. type === 'error' ? 'bg-red-50 text-red-700 border border-red-200' :
  72. type === 'warning' ? 'bg-yellow-50 text-yellow-700 border border-yellow-200' :
  73. 'bg-blue-50 text-blue-700 border border-blue-200'
  74. }`;
  75. // Show message with animation
  76. requestAnimationFrame(() => {
  77. statusMessage.classList.remove('opacity-0', '-translate-y-2');
  78. statusMessage.classList.add('opacity-100', 'translate-y-0');
  79. });
  80. // Hide message after 5 seconds
  81. setTimeout(() => {
  82. statusMessage.classList.remove('opacity-100', 'translate-y-0');
  83. statusMessage.classList.add('opacity-0', '-translate-y-2');
  84. }, 5000);
  85. }
  86. // Function to log messages
  87. function logMessage(message, type = LOG_TYPE.DEBUG) {
  88. console.log(`[${type}] ${message}`);
  89. }
  90. // Initialize IndexedDB for preview caching (shared with playlists page)
  91. async function initPreviewCacheDB() {
  92. if (previewCacheDB) return previewCacheDB;
  93. return new Promise((resolve, reject) => {
  94. const request = indexedDB.open(PREVIEW_CACHE_DB_NAME, PREVIEW_CACHE_DB_VERSION);
  95. request.onerror = () => {
  96. logMessage('Failed to open preview cache database', LOG_TYPE.ERROR);
  97. reject(request.error);
  98. };
  99. request.onsuccess = () => {
  100. previewCacheDB = request.result;
  101. logMessage('Preview cache database opened successfully', LOG_TYPE.DEBUG);
  102. resolve(previewCacheDB);
  103. };
  104. request.onupgradeneeded = (event) => {
  105. const db = event.target.result;
  106. // Create object store for preview cache
  107. const store = db.createObjectStore(PREVIEW_CACHE_STORE_NAME, { keyPath: 'pattern' });
  108. store.createIndex('lastAccessed', 'lastAccessed', { unique: false });
  109. store.createIndex('size', 'size', { unique: false });
  110. logMessage('Preview cache database schema created', LOG_TYPE.DEBUG);
  111. };
  112. });
  113. }
  114. // Get preview from IndexedDB cache
  115. async function getPreviewFromCache(pattern) {
  116. try {
  117. if (!previewCacheDB) await initPreviewCacheDB();
  118. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  119. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  120. return new Promise((resolve, reject) => {
  121. const request = store.get(pattern);
  122. request.onsuccess = () => {
  123. const result = request.result;
  124. if (result) {
  125. // Update last accessed time
  126. result.lastAccessed = Date.now();
  127. store.put(result);
  128. resolve(result.data);
  129. } else {
  130. resolve(null);
  131. }
  132. };
  133. request.onerror = () => reject(request.error);
  134. });
  135. } catch (error) {
  136. logMessage(`Error getting preview from cache: ${error.message}`, LOG_TYPE.WARNING);
  137. return null;
  138. }
  139. }
  140. // Clear a specific pattern from IndexedDB cache
  141. async function clearPatternFromIndexedDB(pattern) {
  142. try {
  143. if (!previewCacheDB) await initPreviewCacheDB();
  144. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  145. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  146. await new Promise((resolve, reject) => {
  147. const deleteRequest = store.delete(pattern);
  148. deleteRequest.onsuccess = () => {
  149. logMessage(`Cleared ${pattern} from IndexedDB cache`, LOG_TYPE.DEBUG);
  150. resolve();
  151. };
  152. deleteRequest.onerror = () => {
  153. logMessage(`Failed to clear ${pattern} from IndexedDB: ${deleteRequest.error}`, LOG_TYPE.WARNING);
  154. reject(deleteRequest.error);
  155. };
  156. });
  157. } catch (error) {
  158. logMessage(`Error clearing pattern from IndexedDB: ${error.message}`, LOG_TYPE.WARNING);
  159. }
  160. }
  161. // Save preview to IndexedDB cache with size management
  162. async function savePreviewToCache(pattern, previewData) {
  163. try {
  164. if (!previewCacheDB) await initPreviewCacheDB();
  165. // Validate preview data before attempting to fetch
  166. if (!previewData || !previewData.image_data) {
  167. logMessage(`Invalid preview data for ${pattern}, skipping cache save`, LOG_TYPE.WARNING);
  168. return;
  169. }
  170. // Convert preview URL to blob for size calculation
  171. const response = await fetch(previewData.image_data);
  172. const blob = await response.blob();
  173. const size = blob.size;
  174. // Check if we need to free up space
  175. await managePreviewCacheSize(size);
  176. const cacheEntry = {
  177. pattern: pattern,
  178. data: previewData,
  179. size: size,
  180. lastAccessed: Date.now(),
  181. created: Date.now()
  182. };
  183. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  184. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  185. return new Promise((resolve, reject) => {
  186. const request = store.put(cacheEntry);
  187. request.onsuccess = () => {
  188. logMessage(`Preview cached for ${pattern} (${(size / 1024).toFixed(1)}KB)`, LOG_TYPE.DEBUG);
  189. resolve();
  190. };
  191. request.onerror = () => reject(request.error);
  192. });
  193. } catch (error) {
  194. logMessage(`Error saving preview to cache: ${error.message}`, LOG_TYPE.WARNING);
  195. }
  196. }
  197. // Manage cache size by removing least recently used items
  198. async function managePreviewCacheSize(newItemSize) {
  199. try {
  200. const currentSize = await getPreviewCacheSize();
  201. if (currentSize + newItemSize <= MAX_CACHE_SIZE_BYTES) {
  202. return; // No cleanup needed
  203. }
  204. logMessage(`Cache size would exceed limit (${((currentSize + newItemSize) / 1024 / 1024).toFixed(1)}MB), cleaning up...`, LOG_TYPE.DEBUG);
  205. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  206. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  207. const index = store.index('lastAccessed');
  208. // Get all entries sorted by last accessed (oldest first)
  209. const entries = await new Promise((resolve, reject) => {
  210. const request = index.getAll();
  211. request.onsuccess = () => resolve(request.result);
  212. request.onerror = () => reject(request.error);
  213. });
  214. // Sort by last accessed time (oldest first)
  215. entries.sort((a, b) => a.lastAccessed - b.lastAccessed);
  216. let freedSpace = 0;
  217. const targetSpace = newItemSize + (MAX_CACHE_SIZE_BYTES * 0.1); // Free 10% extra buffer
  218. for (const entry of entries) {
  219. if (freedSpace >= targetSpace) break;
  220. await new Promise((resolve, reject) => {
  221. const deleteRequest = store.delete(entry.pattern);
  222. deleteRequest.onsuccess = () => {
  223. freedSpace += entry.size;
  224. logMessage(`Evicted cached preview for ${entry.pattern} (${(entry.size / 1024).toFixed(1)}KB)`, LOG_TYPE.DEBUG);
  225. resolve();
  226. };
  227. deleteRequest.onerror = () => reject(deleteRequest.error);
  228. });
  229. }
  230. logMessage(`Freed ${(freedSpace / 1024 / 1024).toFixed(1)}MB from preview cache`, LOG_TYPE.DEBUG);
  231. } catch (error) {
  232. logMessage(`Error managing cache size: ${error.message}`, LOG_TYPE.WARNING);
  233. }
  234. }
  235. // Get current cache size
  236. async function getPreviewCacheSize() {
  237. try {
  238. if (!previewCacheDB) return 0;
  239. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readonly');
  240. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  241. return new Promise((resolve, reject) => {
  242. const request = store.getAll();
  243. request.onsuccess = () => {
  244. const totalSize = request.result.reduce((sum, entry) => sum + (entry.size || 0), 0);
  245. resolve(totalSize);
  246. };
  247. request.onerror = () => reject(request.error);
  248. });
  249. } catch (error) {
  250. logMessage(`Error getting cache size: ${error.message}`, LOG_TYPE.WARNING);
  251. return 0;
  252. }
  253. }
  254. // Preload images in batch
  255. async function preloadImages(urls) {
  256. const promises = urls.map(url => {
  257. return new Promise((resolve, reject) => {
  258. if (imageCache.has(url)) {
  259. resolve(imageCache.get(url));
  260. return;
  261. }
  262. const img = new Image();
  263. img.onload = () => {
  264. imageCache.set(url, img);
  265. resolve(img);
  266. };
  267. img.onerror = reject;
  268. img.src = url;
  269. });
  270. });
  271. return Promise.allSettled(promises);
  272. }
  273. // Initialize Intersection Observer for lazy loading
  274. function initPreviewObserver() {
  275. if (previewObserver) {
  276. previewObserver.disconnect();
  277. }
  278. previewObserver = new IntersectionObserver((entries) => {
  279. entries.forEach(entry => {
  280. if (entry.isIntersecting) {
  281. const previewContainer = entry.target;
  282. const pattern = previewContainer.dataset.pattern;
  283. if (pattern) {
  284. addPatternToBatch(pattern, previewContainer);
  285. previewObserver.unobserve(previewContainer);
  286. }
  287. }
  288. });
  289. }, {
  290. rootMargin: '200px 0px',
  291. threshold: 0.1
  292. });
  293. }
  294. // Add pattern to pending batch for efficient loading
  295. async function addPatternToBatch(pattern, element) {
  296. // Check in-memory cache first
  297. if (previewCache.has(pattern)) {
  298. const previewData = previewCache.get(pattern);
  299. if (previewData && !previewData.error) {
  300. if (element) {
  301. updatePreviewElement(element, previewData.image_data);
  302. }
  303. }
  304. return;
  305. }
  306. // Check IndexedDB cache
  307. const cachedData = await getPreviewFromCache(pattern);
  308. if (cachedData && !cachedData.error) {
  309. // Add to in-memory cache for faster access
  310. previewCache.set(pattern, cachedData);
  311. if (element) {
  312. updatePreviewElement(element, cachedData.image_data);
  313. }
  314. return;
  315. }
  316. // Check if this is a newly uploaded pattern
  317. const isNewUpload = element?.dataset.isNewUpload === 'true';
  318. // Reset retry flags when starting fresh
  319. if (element) {
  320. element.dataset.retryCount = '0';
  321. element.dataset.hasTriedIndividual = 'false';
  322. }
  323. // Add loading indicator with better styling
  324. if (!element.querySelector('img')) {
  325. const loadingText = isNewUpload ? 'Generating preview...' : 'Loading...';
  326. element.innerHTML = `
  327. <div class="absolute inset-0 flex items-center justify-center bg-slate-100 rounded-full">
  328. <div class="bg-slate-200 rounded-full h-8 w-8 flex items-center justify-center">
  329. <div class="bg-slate-500 rounded-full h-4 w-4"></div>
  330. </div>
  331. </div>
  332. <div class="absolute inset-0 flex items-center justify-center">
  333. <div class="text-xs text-slate-500 mt-12">${loadingText}</div>
  334. </div>
  335. `;
  336. }
  337. // Add to pending batch
  338. pendingPatterns.set(pattern, element);
  339. // Process batch immediately if it's full or if it's a new upload
  340. if (pendingPatterns.size >= LAZY_BATCH_SIZE || isNewUpload) {
  341. processPendingBatch();
  342. } else {
  343. // Set a timeout to process smaller batches if they don't fill up
  344. if (batchTimeout) {
  345. clearTimeout(batchTimeout);
  346. }
  347. batchTimeout = setTimeout(() => {
  348. if (pendingPatterns.size > 0) {
  349. processPendingBatch();
  350. }
  351. }, 500); // Process after 500ms if batch doesn't fill up
  352. }
  353. }
  354. // Update preview element with smooth transition
  355. function updatePreviewElement(element, imageUrl) {
  356. const img = new Image();
  357. img.onload = () => {
  358. element.innerHTML = '';
  359. element.appendChild(img);
  360. img.className = 'w-full h-full object-contain transition-opacity duration-300';
  361. img.style.opacity = '0';
  362. requestAnimationFrame(() => {
  363. img.style.opacity = '1';
  364. });
  365. // Mark element as loaded to prevent duplicate loading attempts
  366. element.dataset.loaded = 'true';
  367. };
  368. img.src = imageUrl;
  369. img.alt = 'Pattern Preview';
  370. }
  371. // Process pending patterns in batches
  372. async function processPendingBatch() {
  373. if (pendingPatterns.size === 0) return;
  374. // Clear any pending timeout since we're processing now
  375. if (batchTimeout) {
  376. clearTimeout(batchTimeout);
  377. batchTimeout = null;
  378. }
  379. // Create a copy of current pending patterns and clear the original
  380. const currentBatch = new Map(pendingPatterns);
  381. pendingPatterns.clear();
  382. const patternsToLoad = Array.from(currentBatch.keys());
  383. try {
  384. logMessage(`Loading batch of ${patternsToLoad.length} pattern previews`, LOG_TYPE.DEBUG);
  385. const response = await fetch('/preview_thr_batch', {
  386. method: 'POST',
  387. headers: { 'Content-Type': 'application/json' },
  388. body: JSON.stringify({ file_names: patternsToLoad })
  389. });
  390. if (response.ok) {
  391. const results = await response.json();
  392. // Process all results
  393. for (const [pattern, data] of Object.entries(results)) {
  394. const element = currentBatch.get(pattern);
  395. if (data && !data.error && data.image_data) {
  396. // Cache in memory with size limit
  397. if (previewCache.size > 100) { // Limit cache size
  398. const oldestKey = previewCache.keys().next().value;
  399. previewCache.delete(oldestKey);
  400. }
  401. previewCache.set(pattern, data);
  402. // Save to IndexedDB cache for persistence
  403. await savePreviewToCache(pattern, data);
  404. if (element) {
  405. updatePreviewElement(element, data.image_data);
  406. }
  407. } else {
  408. handleLoadError(pattern, element, data?.error || 'Failed to load preview');
  409. }
  410. }
  411. }
  412. } catch (error) {
  413. logMessage(`Error loading preview batch: ${error.message}`, LOG_TYPE.ERROR);
  414. // Handle error for each pattern in batch
  415. for (const pattern of patternsToLoad) {
  416. const element = currentBatch.get(pattern);
  417. handleLoadError(pattern, element, error.message);
  418. }
  419. }
  420. }
  421. // Trigger preview loading for currently visible patterns
  422. function triggerPreviewLoadingForVisible() {
  423. // Get all pattern cards currently in the DOM
  424. const patternCards = document.querySelectorAll('.pattern-card');
  425. patternCards.forEach(card => {
  426. const pattern = card.dataset.pattern;
  427. const previewContainer = card.querySelector('.pattern-preview');
  428. // Check if this pattern needs preview loading
  429. if (pattern && !previewCache.has(pattern) && !pendingPatterns.has(pattern)) {
  430. // Add to batch for immediate loading
  431. addPatternToBatch(pattern, previewContainer);
  432. }
  433. });
  434. // Process any pending previews immediately
  435. if (pendingPatterns.size > 0) {
  436. processPendingBatch();
  437. }
  438. }
  439. // Load individual pattern preview (fallback when batch loading fails)
  440. async function loadIndividualPreview(pattern, element) {
  441. try {
  442. logMessage(`Loading individual preview for ${pattern}`, LOG_TYPE.DEBUG);
  443. const response = await fetch('/preview_thr_batch', {
  444. method: 'POST',
  445. headers: { 'Content-Type': 'application/json' },
  446. body: JSON.stringify({ file_names: [pattern] })
  447. });
  448. if (response.ok) {
  449. const results = await response.json();
  450. const data = results[pattern];
  451. if (data && !data.error && data.image_data) {
  452. // Cache in memory with size limit
  453. if (previewCache.size > 100) { // Limit cache size
  454. const oldestKey = previewCache.keys().next().value;
  455. previewCache.delete(oldestKey);
  456. }
  457. previewCache.set(pattern, data);
  458. // Save to IndexedDB cache for persistence
  459. await savePreviewToCache(pattern, data);
  460. if (element) {
  461. updatePreviewElement(element, data.image_data);
  462. }
  463. logMessage(`Individual preview loaded successfully for ${pattern}`, LOG_TYPE.DEBUG);
  464. } else {
  465. throw new Error(data?.error || 'Failed to load preview data');
  466. }
  467. } else {
  468. throw new Error(`HTTP error! status: ${response.status}`);
  469. }
  470. } catch (error) {
  471. logMessage(`Error loading individual preview for ${pattern}: ${error.message}`, LOG_TYPE.ERROR);
  472. // Continue with normal error handling
  473. handleLoadError(pattern, element, error.message);
  474. }
  475. }
  476. // Handle load errors with retry logic
  477. function handleLoadError(pattern, element, error) {
  478. const retryCount = element.dataset.retryCount || 0;
  479. const isNewUpload = element.dataset.isNewUpload === 'true';
  480. const hasTriedIndividual = element.dataset.hasTriedIndividual === 'true';
  481. // Use longer delays for newly uploaded patterns
  482. const retryDelay = isNewUpload ? RETRY_DELAY * 2 : RETRY_DELAY;
  483. const maxRetries = isNewUpload ? MAX_RETRIES * 2 : MAX_RETRIES;
  484. if (retryCount < maxRetries) {
  485. // Update retry count
  486. element.dataset.retryCount = parseInt(retryCount) + 1;
  487. // Determine retry strategy
  488. let retryStrategy = 'batch';
  489. if (retryCount >= 1 && !hasTriedIndividual) {
  490. // After first batch attempt fails, try individual loading
  491. retryStrategy = 'individual';
  492. element.dataset.hasTriedIndividual = 'true';
  493. }
  494. // Show retry message with different text for new uploads and retry strategies
  495. let retryText;
  496. if (isNewUpload) {
  497. retryText = retryStrategy === 'individual' ?
  498. `Trying individual load... (${retryCount + 1}/${maxRetries})` :
  499. `Generating preview... (${retryCount + 1}/${maxRetries})`;
  500. } else {
  501. retryText = retryStrategy === 'individual' ?
  502. `Trying individual load... (${retryCount + 1}/${maxRetries})` :
  503. `Retrying... (${retryCount + 1}/${maxRetries})`;
  504. }
  505. element.innerHTML = `
  506. <div class="absolute inset-0 flex items-center justify-center bg-slate-100 rounded-full">
  507. <div class="text-xs text-slate-500 text-center">
  508. <div>${isNewUpload ? 'Processing new pattern' : 'Failed to load'}</div>
  509. <div>${retryText}</div>
  510. </div>
  511. </div>
  512. `;
  513. // Retry after delay with appropriate strategy
  514. setTimeout(() => {
  515. if (retryStrategy === 'individual') {
  516. loadIndividualPreview(pattern, element);
  517. } else {
  518. addPatternToBatch(pattern, element);
  519. }
  520. }, retryDelay);
  521. } else {
  522. // Show final error state
  523. element.innerHTML = `
  524. <div class="absolute inset-0 flex items-center justify-center bg-slate-100 rounded-full">
  525. <div class="text-xs text-slate-500 text-center">
  526. <div>Failed to load</div>
  527. <div>Click to retry</div>
  528. </div>
  529. </div>
  530. `;
  531. // Add click handler for manual retry
  532. element.onclick = () => {
  533. element.dataset.retryCount = '0';
  534. element.dataset.hasTriedIndividual = 'false';
  535. addPatternToBatch(pattern, element);
  536. };
  537. }
  538. previewCache.set(pattern, { error: true });
  539. }
  540. // Load and display patterns
  541. async function loadPatterns(forceRefresh = false) {
  542. try {
  543. logMessage('Loading patterns...', LOG_TYPE.INFO);
  544. // First load basic patterns list for fast initial display
  545. logMessage('Fetching basic patterns list from server', LOG_TYPE.DEBUG);
  546. const basicPatterns = await getCachedPatternFiles(forceRefresh);
  547. const thrPatterns = basicPatterns.filter(file => file.endsWith('.thr'));
  548. logMessage(`Received ${thrPatterns.length} basic patterns from server`, LOG_TYPE.INFO);
  549. // Store basic patterns and display immediately
  550. let patterns = [...thrPatterns];
  551. allPatterns = patterns;
  552. // Sort patterns alphabetically to match final enhanced sorting
  553. const sortedPatterns = patterns.sort((a, b) => a.localeCompare(b));
  554. allPatterns = sortedPatterns;
  555. // Display basic patterns immediately for fast initial load
  556. logMessage('Displaying initial patterns...', LOG_TYPE.INFO);
  557. displayPatternBatch();
  558. logMessage('Initial patterns loaded successfully.', LOG_TYPE.SUCCESS);
  559. // Load metadata in background for enhanced features
  560. setTimeout(async () => {
  561. try {
  562. // Cancel any previous metadata loading request
  563. if (metadataAbortController) {
  564. metadataAbortController.abort();
  565. }
  566. // Create new AbortController for this request with timeout
  567. metadataAbortController = new AbortController();
  568. // Set a timeout to prevent hanging on slow Pi systems
  569. const timeoutId = setTimeout(() => {
  570. metadataAbortController.abort();
  571. logMessage('Metadata loading timed out after 30 seconds', LOG_TYPE.WARNING);
  572. }, 30000); // 30 second timeout
  573. logMessage('Loading enhanced metadata...', LOG_TYPE.DEBUG);
  574. const metadataResponse = await fetch('/list_theta_rho_files_with_metadata', {
  575. signal: metadataAbortController.signal,
  576. headers: {
  577. 'Cache-Control': 'no-cache'
  578. }
  579. });
  580. // Clear timeout if request succeeds
  581. clearTimeout(timeoutId);
  582. const patternsWithMetadata = await metadataResponse.json();
  583. // Store enhanced patterns data
  584. allPatternsWithMetadata = [...patternsWithMetadata];
  585. // Update category filter dropdown now that we have metadata
  586. updateBrowseCategoryFilter();
  587. // Enable sort controls and display patterns consistently
  588. enableSortControls();
  589. logMessage(`Enhanced metadata loaded for ${patternsWithMetadata.length} patterns`, LOG_TYPE.SUCCESS);
  590. // Clear the controller reference since request completed
  591. metadataAbortController = null;
  592. } catch (metadataError) {
  593. if (metadataError.name === 'AbortError') {
  594. logMessage('Metadata loading cancelled or timed out', LOG_TYPE.WARNING);
  595. } else {
  596. logMessage(`Failed to load enhanced metadata: ${metadataError.message}`, LOG_TYPE.WARNING);
  597. }
  598. // Create basic metadata from file list to populate categories
  599. if (allPatterns && allPatterns.length > 0) {
  600. allPatternsWithMetadata = allPatterns.map(pattern => {
  601. const pathParts = pattern.split('/');
  602. const category = pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : 'root';
  603. const fileName = pathParts[pathParts.length - 1].replace('.thr', '');
  604. return {
  605. path: pattern,
  606. name: fileName,
  607. category: category,
  608. date_modified: 0,
  609. coordinates_count: 0
  610. };
  611. });
  612. // Update category filter with basic data
  613. updateBrowseCategoryFilter();
  614. logMessage('Using basic category data (metadata unavailable)', LOG_TYPE.INFO);
  615. }
  616. metadataAbortController = null;
  617. }
  618. }, 100); // Small delay to let initial render complete
  619. if (forceRefresh) {
  620. showStatusMessage('Patterns list refreshed successfully', 'success');
  621. }
  622. } catch (error) {
  623. logMessage(`Error loading patterns: ${error.message}`, LOG_TYPE.ERROR);
  624. console.error('Full error:', error);
  625. showStatusMessage('Failed to load patterns', 'error');
  626. }
  627. }
  628. // Display a batch of patterns with improved initial load
  629. function displayPatternBatch() {
  630. const patternGrid = document.querySelector('.grid');
  631. if (!patternGrid) {
  632. logMessage('Pattern grid not found in the DOM', LOG_TYPE.ERROR);
  633. return;
  634. }
  635. const start = currentBatch * BATCH_SIZE;
  636. const end = Math.min(start + BATCH_SIZE, allPatterns.length);
  637. const batchPatterns = allPatterns.slice(start, end);
  638. // Display batch patterns
  639. batchPatterns.forEach(pattern => {
  640. const patternCard = createPatternCard(pattern);
  641. patternGrid.appendChild(patternCard);
  642. });
  643. // If there are more patterns to load, set up the observer for the last few cards
  644. if (end < allPatterns.length) {
  645. const lastCards = Array.from(patternGrid.children).slice(-3); // Observe last 3 cards
  646. lastCards.forEach(card => {
  647. const observer = new IntersectionObserver((entries) => {
  648. if (entries[0].isIntersecting) {
  649. currentBatch++;
  650. displayPatternBatch();
  651. observer.disconnect();
  652. }
  653. }, {
  654. rootMargin: '200px 0px',
  655. threshold: 0.1
  656. });
  657. observer.observe(card);
  658. });
  659. }
  660. }
  661. // Create a pattern card element
  662. function createPatternCard(pattern) {
  663. const card = document.createElement('div');
  664. card.className = 'pattern-card group relative flex flex-col items-center gap-3 bg-gray-50';
  665. card.dataset.pattern = pattern;
  666. // Create preview container with proper styling for loading indicator
  667. const previewContainer = document.createElement('div');
  668. previewContainer.className = 'w-32 h-32 rounded-full shadow-md relative pattern-preview';
  669. previewContainer.dataset.pattern = pattern;
  670. // Add loading indicator
  671. previewContainer.innerHTML = '<div class="absolute inset-0 flex items-center justify-center"><div class="bg-slate-200 rounded-full h-8 w-8 flex items-center justify-center"><div class="bg-slate-500 rounded-full h-4 w-4"></div></div></div>';
  672. // Add play button overlay (centered, hidden by default, shown on hover)
  673. const playOverlay = document.createElement('div');
  674. playOverlay.className = 'absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 cursor-pointer';
  675. playOverlay.innerHTML = '<div class="bg-white rounded-full p-2 shadow-lg flex items-center justify-center w-10 h-10"><span class="material-icons text-lg text-gray-800">play_arrow</span></div>';
  676. playOverlay.title = 'Preview pattern';
  677. playOverlay.addEventListener('click', (e) => {
  678. e.stopPropagation(); // Prevent card selection
  679. openAnimatedPreview(pattern);
  680. });
  681. previewContainer.appendChild(playOverlay);
  682. // Add heart favorite button (top-right corner)
  683. const heartButton = document.createElement('div');
  684. const isAlreadyFavorite = favoritePatterns.has(pattern);
  685. const heartOpacity = isAlreadyFavorite ? 'opacity-100' : 'opacity-0 group-hover:opacity-100';
  686. heartButton.className = `absolute top-2 right-2 w-7 h-7 cursor-pointer ${heartOpacity} transition-opacity duration-200 z-10 bg-white/90 rounded-full shadow-sm flex items-center justify-center`;
  687. const heartIcon = isAlreadyFavorite ? 'favorite' : 'favorite_border';
  688. const heartColor = isAlreadyFavorite ? 'text-red-500 hover:text-red-600' : 'text-gray-400 hover:text-red-500';
  689. heartButton.innerHTML = `<span class="material-icons text-lg ${heartColor} transition-colors" id="heart-${pattern.replace(/[^a-zA-Z0-9]/g, '_')}">${heartIcon}</span>`;
  690. heartButton.title = isAlreadyFavorite ? 'Remove from favorites' : 'Add to favorites';
  691. heartButton.addEventListener('click', (e) => {
  692. e.stopPropagation(); // Prevent card selection
  693. toggleFavorite(pattern);
  694. });
  695. // Note: Heart button will be added to card, not previewContainer to avoid circular clipping
  696. // Create pattern name
  697. const patternName = document.createElement('p');
  698. patternName.className = 'text-gray-700 text-sm font-medium text-center truncate w-full';
  699. patternName.textContent = pattern.replace('.thr', '').split('/').pop();
  700. // Add click handler
  701. card.onclick = () => selectPattern(pattern, card);
  702. // Check if preview is already in cache
  703. const previewData = previewCache.get(pattern);
  704. if (previewData && !previewData.error && previewData.image_data) {
  705. updatePreviewElement(previewContainer, previewData.image_data);
  706. } else {
  707. // Start observing the preview container for lazy loading
  708. previewObserver.observe(previewContainer);
  709. }
  710. card.appendChild(previewContainer);
  711. card.appendChild(patternName);
  712. // Add heart button to card (not previewContainer) to avoid circular clipping
  713. card.appendChild(heartButton);
  714. return card;
  715. }
  716. // Select a pattern
  717. function selectPattern(pattern, card) {
  718. // Remove selected class from all cards
  719. document.querySelectorAll('.pattern-card').forEach(c => {
  720. c.classList.remove('selected');
  721. });
  722. // Add selected class to clicked card
  723. card.classList.add('selected');
  724. // Show pattern preview
  725. showPatternPreview(pattern);
  726. }
  727. // Show pattern preview
  728. async function showPatternPreview(pattern) {
  729. try {
  730. // Check in-memory cache first
  731. let data = previewCache.get(pattern);
  732. // If not in cache, fetch it
  733. if (!data) {
  734. const response = await fetch('/preview_thr_batch', {
  735. method: 'POST',
  736. headers: { 'Content-Type': 'application/json' },
  737. body: JSON.stringify({ file_names: [pattern] })
  738. });
  739. if (!response.ok) {
  740. throw new Error(`HTTP error! status: ${response.status}`);
  741. }
  742. const results = await response.json();
  743. data = results[pattern];
  744. if (data && !data.error) {
  745. // Cache in memory
  746. previewCache.set(pattern, data);
  747. } else {
  748. throw new Error(data?.error || 'Failed to get preview data');
  749. }
  750. }
  751. const previewPanel = document.getElementById('patternPreviewPanel');
  752. const layoutContainer = document.querySelector('.layout-content-container');
  753. // Update preview content
  754. if (data.image_data) {
  755. document.getElementById('patternPreviewImage').src = data.image_data;
  756. }
  757. // Set pattern name in the preview panel
  758. const patternName = pattern.replace('.thr', '').split('/').pop();
  759. document.getElementById('patternPreviewTitle').textContent = patternName;
  760. // Format and display coordinates
  761. const formatCoordinate = (coord) => {
  762. if (!coord) return '(0, 0)';
  763. const x = coord.x !== undefined ? coord.x.toFixed(1) : '0.0';
  764. const y = coord.y !== undefined ? coord.y.toFixed(1) : '0.0';
  765. return `(${x}, ${y})`;
  766. };
  767. document.getElementById('firstCoordinate').textContent = formatCoordinate(data.first_coordinate);
  768. document.getElementById('lastCoordinate').textContent = formatCoordinate(data.last_coordinate);
  769. // Show preview panel
  770. previewPanel.classList.remove('translate-x-full');
  771. if (window.innerWidth >= 1024) {
  772. // For large screens, show preview alongside content
  773. layoutContainer.parentElement.classList.add('preview-open');
  774. previewPanel.classList.remove('lg:opacity-0', 'lg:pointer-events-none');
  775. } else {
  776. // For small screens, show preview as overlay
  777. layoutContainer.parentElement.classList.remove('preview-open');
  778. }
  779. // Setup preview panel events
  780. setupPreviewPanelEvents(pattern);
  781. } catch (error) {
  782. logMessage(`Error showing preview for ${pattern}: ${error.message}`, LOG_TYPE.ERROR);
  783. // Show error state in preview panel instead of hiding it
  784. showPreviewError(pattern, error.message);
  785. }
  786. }
  787. function showPreviewError(pattern, errorMessage) {
  788. const previewPanel = document.getElementById('patternPreviewPanel');
  789. const layoutContainer = document.querySelector('.layout-content-container');
  790. // Show error state in preview panel
  791. const patternName = pattern.replace('.thr', '').split('/').pop();
  792. document.getElementById('patternPreviewTitle').textContent = `Error: ${patternName}`;
  793. // Show error image or placeholder
  794. const img = document.getElementById('patternPreviewImage');
  795. img.src = 'data:image/svg+xml;base64,' + btoa(`
  796. <svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
  797. <rect width="100%" height="100%" fill="#f3f4f6"/>
  798. <text x="50%" y="40%" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" fill="#6b7280">
  799. Pattern Not Found
  800. </text>
  801. <text x="50%" y="60%" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#9ca3af">
  802. ${patternName}
  803. </text>
  804. <text x="50%" y="75%" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#ef4444">
  805. File may have been deleted
  806. </text>
  807. </svg>
  808. `);
  809. // Clear coordinates
  810. document.getElementById('firstCoordinate').textContent = '(0, 0)';
  811. document.getElementById('lastCoordinate').textContent = '(0, 0)';
  812. // Show preview panel with error state
  813. previewPanel.classList.remove('translate-x-full');
  814. if (window.innerWidth >= 1024) {
  815. layoutContainer.parentElement.classList.add('preview-open');
  816. previewPanel.classList.remove('lg:opacity-0', 'lg:pointer-events-none');
  817. } else {
  818. layoutContainer.parentElement.classList.remove('preview-open');
  819. }
  820. // Setup events so user can still close the panel
  821. setupPreviewPanelEvents(pattern);
  822. }
  823. function hidePatternPreview() {
  824. const previewPanel = document.getElementById('patternPreviewPanel');
  825. const layoutContainer = document.querySelector('.layout-content-container');
  826. previewPanel.classList.add('translate-x-full');
  827. if (window.innerWidth >= 1024) {
  828. previewPanel.classList.add('lg:opacity-0', 'lg:pointer-events-none');
  829. }
  830. layoutContainer.parentElement.classList.remove('preview-open');
  831. }
  832. // Add window resize handler
  833. window.addEventListener('resize', () => {
  834. const previewPanel = document.getElementById('patternPreviewPanel');
  835. const layoutContainer = document.querySelector('.layout-content-container');
  836. if (window.innerWidth >= 1024) {
  837. if (!previewPanel.classList.contains('translate-x-full')) {
  838. layoutContainer.parentElement.classList.add('preview-open');
  839. previewPanel.classList.remove('lg:opacity-0', 'lg:pointer-events-none');
  840. }
  841. } else {
  842. layoutContainer.parentElement.classList.remove('preview-open');
  843. previewPanel.classList.add('lg:opacity-0', 'lg:pointer-events-none');
  844. }
  845. // Update category filter display names for new screen size
  846. updateBrowseCategoryFilter();
  847. });
  848. // Setup preview panel events
  849. function setupPreviewPanelEvents(pattern) {
  850. const panel = document.getElementById('patternPreviewPanel');
  851. const closeButton = document.getElementById('closePreviewPanel');
  852. const playButton = document.getElementById('playPattern');
  853. const deleteButton = document.getElementById('deletePattern');
  854. const preExecutionInputs = document.querySelectorAll('input[name="preExecutionAction"]');
  855. const previewPlayOverlay = document.getElementById('previewPlayOverlay');
  856. // Close panel when clicking the close button
  857. closeButton.onclick = () => {
  858. hidePatternPreview();
  859. // Remove selected state from all cards when closing
  860. document.querySelectorAll('.pattern-card').forEach(c => {
  861. c.classList.remove('selected');
  862. });
  863. };
  864. // Handle play button overlay click in preview panel
  865. if (previewPlayOverlay) {
  866. previewPlayOverlay.onclick = () => {
  867. openAnimatedPreview(pattern);
  868. };
  869. }
  870. // Handle play button click
  871. playButton.onclick = async () => {
  872. if (!pattern) {
  873. showStatusMessage('No pattern selected', 'error');
  874. return;
  875. }
  876. try {
  877. // Show the preview modal
  878. if (window.openPlayerPreviewModal) {
  879. window.openPlayerPreviewModal();
  880. }
  881. // Get the selected pre-execution action
  882. const preExecutionInput = document.querySelector('input[name="preExecutionAction"]:checked');
  883. const preExecution = preExecutionInput ? preExecutionInput.value : 'none';
  884. const response = await fetch('/run_theta_rho', {
  885. method: 'POST',
  886. headers: {
  887. 'Content-Type': 'application/json'
  888. },
  889. body: JSON.stringify({
  890. file_name: pattern,
  891. pre_execution: preExecution
  892. })
  893. });
  894. const data = await response.json();
  895. if (response.ok) {
  896. showStatusMessage(`Running pattern: ${pattern.split('/').pop()}`, 'success');
  897. hidePatternPreview();
  898. // Show the preview modal when a pattern starts
  899. if (typeof setModalVisibility === 'function') {
  900. setModalVisibility(true, false);
  901. }
  902. } else {
  903. let errorMsg = data.detail || 'Failed to run pattern';
  904. let errorType = 'error';
  905. // Handle specific error cases with appropriate messaging
  906. if (data.detail === 'Connection not established') {
  907. errorMsg = 'Please connect to the device before running a pattern';
  908. errorType = 'warning';
  909. } else if (response.status === 409) {
  910. errorMsg = 'Another pattern is already running. Please stop the current pattern first.';
  911. errorType = 'warning';
  912. } else if (response.status === 404) {
  913. errorMsg = 'Pattern file not found. Please refresh the page and try again.';
  914. errorType = 'error';
  915. } else if (response.status === 400) {
  916. errorMsg = 'Invalid request. Please check your settings and try again.';
  917. errorType = 'error';
  918. } else if (response.status === 500) {
  919. errorMsg = 'Server error. Please try again later.';
  920. errorType = 'error';
  921. }
  922. showStatusMessage(errorMsg, errorType);
  923. return;
  924. }
  925. } catch (error) {
  926. console.error('Error running pattern:', error);
  927. // Handle network errors specifically
  928. if (error.name === 'TypeError' && error.message.includes('fetch')) {
  929. showStatusMessage('Network error. Please check your connection and try again.', 'error');
  930. } else if (error.message && error.message.includes('409')) {
  931. showStatusMessage('Another pattern is already running', 'warning');
  932. } else if (error.message) {
  933. showStatusMessage(error.message, 'error');
  934. } else {
  935. showStatusMessage('Failed to run pattern', 'error');
  936. }
  937. }
  938. };
  939. // Handle delete button click
  940. deleteButton.onclick = async () => {
  941. if (!pattern.startsWith('custom_patterns/')) {
  942. logMessage('Cannot delete built-in patterns', LOG_TYPE.WARNING);
  943. showStatusMessage('Cannot delete built-in patterns', 'warning');
  944. return;
  945. }
  946. if (confirm('Are you sure you want to delete this pattern?')) {
  947. try {
  948. logMessage(`Deleting pattern: ${pattern}`, LOG_TYPE.INFO);
  949. const response = await fetch('/delete_theta_rho_file', {
  950. method: 'POST',
  951. headers: {
  952. 'Content-Type': 'application/json'
  953. },
  954. body: JSON.stringify({ file_name: pattern })
  955. });
  956. if (!response.ok) {
  957. throw new Error(`HTTP error! status: ${response.status}`);
  958. }
  959. const result = await response.json();
  960. if (result.success) {
  961. logMessage(`Pattern deleted successfully: ${pattern}`, LOG_TYPE.SUCCESS);
  962. showStatusMessage(`Pattern "${pattern.split('/').pop()}" deleted successfully`);
  963. // Invalidate pattern files cache
  964. invalidatePatternFilesCache();
  965. // Clear from in-memory caches
  966. previewCache.delete(pattern);
  967. imageCache.delete(pattern);
  968. // Clear from IndexedDB cache
  969. await clearPatternFromIndexedDB(pattern);
  970. // Clear from localStorage patterns list cache
  971. const cachedPatterns = JSON.parse(localStorage.getItem(PATTERNS_CACHE_KEY) || '{}');
  972. if (cachedPatterns.data) {
  973. const index = cachedPatterns.data.indexOf(pattern);
  974. if (index > -1) {
  975. cachedPatterns.data.splice(index, 1);
  976. localStorage.setItem(PATTERNS_CACHE_KEY, JSON.stringify(cachedPatterns));
  977. }
  978. }
  979. // Remove the pattern card
  980. const selectedCard = document.querySelector('.pattern-card.selected');
  981. if (selectedCard) {
  982. selectedCard.remove();
  983. }
  984. // Close the preview panel
  985. const previewPanel = document.getElementById('patternPreviewPanel');
  986. const layoutContainer = document.querySelector('.layout-content-container');
  987. previewPanel.classList.add('translate-x-full');
  988. if (window.innerWidth >= 1024) {
  989. previewPanel.classList.add('lg:opacity-0', 'lg:pointer-events-none');
  990. }
  991. layoutContainer.parentElement.classList.remove('preview-open');
  992. // Clear the preview panel content
  993. document.getElementById('patternPreviewImage').src = '';
  994. document.getElementById('patternPreviewTitle').textContent = 'Pattern Details';
  995. document.getElementById('firstCoordinate').textContent = '(0, 0)';
  996. document.getElementById('lastCoordinate').textContent = '(0, 0)';
  997. // Refresh the pattern list (cache already invalidated above)
  998. await loadPatterns();
  999. } else {
  1000. throw new Error(result.error || 'Unknown error');
  1001. }
  1002. } catch (error) {
  1003. logMessage(`Failed to delete pattern: ${error.message}`, LOG_TYPE.ERROR);
  1004. showStatusMessage(`Failed to delete pattern: ${error.message}`, 'error');
  1005. }
  1006. }
  1007. };
  1008. // Handle pre-execution action changes
  1009. preExecutionInputs.forEach(input => {
  1010. input.onchange = () => {
  1011. const action = input.parentElement.textContent.trim();
  1012. logMessage(`Pre-execution action changed to: ${action}`, LOG_TYPE.INFO);
  1013. };
  1014. });
  1015. }
  1016. // Search patterns
  1017. // Sort patterns by specified field and direction
  1018. function sortPatterns(patterns, sortField, sortDirection) {
  1019. return patterns.sort((a, b) => {
  1020. let aVal, bVal;
  1021. switch (sortField) {
  1022. case 'name':
  1023. aVal = a.name.toLowerCase();
  1024. bVal = b.name.toLowerCase();
  1025. break;
  1026. case 'date':
  1027. aVal = a.date_modified;
  1028. bVal = b.date_modified;
  1029. break;
  1030. case 'coordinates':
  1031. aVal = a.coordinates_count;
  1032. bVal = b.coordinates_count;
  1033. break;
  1034. case 'favorite':
  1035. // Sort by favorite status first, then by name as secondary sort
  1036. const aIsFavorite = favoritePatterns.has(a.path);
  1037. const bIsFavorite = favoritePatterns.has(b.path);
  1038. if (aIsFavorite && !bIsFavorite) return sortDirection === 'asc' ? -1 : 1;
  1039. if (!aIsFavorite && bIsFavorite) return sortDirection === 'asc' ? 1 : -1;
  1040. // Both have same favorite status, sort by name as secondary sort
  1041. aVal = a.name.toLowerCase();
  1042. bVal = b.name.toLowerCase();
  1043. break;
  1044. default:
  1045. aVal = a.name.toLowerCase();
  1046. bVal = b.name.toLowerCase();
  1047. }
  1048. let result = 0;
  1049. if (aVal < bVal) result = -1;
  1050. else if (aVal > bVal) result = 1;
  1051. return sortDirection === 'asc' ? result : -result;
  1052. });
  1053. }
  1054. // Filter patterns based on current filters
  1055. function filterPatterns(patterns, filters, searchQuery = '') {
  1056. return patterns.filter(pattern => {
  1057. // Category filter
  1058. if (filters.category !== 'all' && pattern.category !== filters.category) {
  1059. return false;
  1060. }
  1061. // Search query filter
  1062. if (searchQuery.trim()) {
  1063. const normalizedQuery = searchQuery.toLowerCase().trim();
  1064. const patternName = pattern.name.toLowerCase();
  1065. const category = pattern.category.toLowerCase();
  1066. return patternName.includes(normalizedQuery) || category.includes(normalizedQuery);
  1067. }
  1068. return true;
  1069. });
  1070. }
  1071. // Apply sorting and filtering to patterns
  1072. function applyPatternsFilteringAndSorting() {
  1073. const searchQuery = document.getElementById('patternSearch')?.value || '';
  1074. // Check if enhanced metadata is available
  1075. if (!allPatternsWithMetadata || allPatternsWithMetadata.length === 0) {
  1076. // Fallback to basic search if metadata not loaded yet
  1077. if (searchQuery.trim()) {
  1078. const filteredPatterns = allPatterns.filter(pattern =>
  1079. pattern.toLowerCase().includes(searchQuery.toLowerCase())
  1080. );
  1081. displayFilteredPatterns(filteredPatterns);
  1082. } else {
  1083. // Just display current batch if no search
  1084. displayPatternBatch();
  1085. }
  1086. return;
  1087. }
  1088. // Start with all available patterns with metadata
  1089. let patterns = [...allPatternsWithMetadata];
  1090. // Apply filters
  1091. patterns = filterPatterns(patterns, currentFilters, searchQuery);
  1092. // Apply sorting
  1093. patterns = sortPatterns(patterns, currentSort.field, currentSort.direction);
  1094. // Update filtered patterns (convert back to path format for compatibility)
  1095. const filteredPatterns = patterns.map(p => p.path);
  1096. // Display filtered patterns
  1097. displayFilteredPatterns(filteredPatterns);
  1098. updateBrowseSortAndFilterUI();
  1099. }
  1100. // Display filtered patterns
  1101. function displayFilteredPatterns(filteredPatterns) {
  1102. const patternGrid = document.querySelector('.grid');
  1103. if (!patternGrid) return;
  1104. patternGrid.innerHTML = '';
  1105. if (filteredPatterns.length === 0) {
  1106. patternGrid.innerHTML = '<div class="col-span-full text-center text-gray-500 py-8">No patterns found</div>';
  1107. return;
  1108. }
  1109. filteredPatterns.forEach(pattern => {
  1110. const patternCard = createPatternCard(pattern);
  1111. patternGrid.appendChild(patternCard);
  1112. });
  1113. // Give the browser a chance to render the cards
  1114. requestAnimationFrame(() => {
  1115. // Trigger preview loading for the search results
  1116. triggerPreviewLoadingForVisible();
  1117. });
  1118. logMessage(`Displaying ${filteredPatterns.length} patterns`, LOG_TYPE.INFO);
  1119. }
  1120. function searchPatterns(query) {
  1121. // Update the search input if called programmatically
  1122. const searchInput = document.getElementById('patternSearch');
  1123. if (searchInput && searchInput.value !== query) {
  1124. searchInput.value = query;
  1125. }
  1126. applyPatternsFilteringAndSorting();
  1127. }
  1128. // Update sort and filter UI to reflect current state
  1129. function updateBrowseSortAndFilterUI() {
  1130. // Update sort direction icon
  1131. const sortDirectionIcon = document.getElementById('browseSortDirectionIcon');
  1132. if (sortDirectionIcon) {
  1133. sortDirectionIcon.textContent = currentSort.direction === 'asc' ? 'arrow_upward' : 'arrow_downward';
  1134. }
  1135. // Update sort field select
  1136. const sortFieldSelect = document.getElementById('browseSortFieldSelect');
  1137. if (sortFieldSelect) {
  1138. sortFieldSelect.value = currentSort.field;
  1139. }
  1140. // Update filter selects
  1141. const categorySelect = document.getElementById('browseCategoryFilterSelect');
  1142. if (categorySelect) {
  1143. categorySelect.value = currentFilters.category;
  1144. }
  1145. }
  1146. // Populate category filter dropdown with available categories (subfolders)
  1147. function updateBrowseCategoryFilter() {
  1148. const categorySelect = document.getElementById('browseCategoryFilterSelect');
  1149. if (!categorySelect) return;
  1150. // Check if metadata is available
  1151. if (!allPatternsWithMetadata || allPatternsWithMetadata.length === 0) {
  1152. // Show basic options if metadata not loaded
  1153. categorySelect.innerHTML = '<option value="all">All Folders (loading...)</option>';
  1154. return;
  1155. }
  1156. // Get unique categories (subfolders)
  1157. const categories = [...new Set(allPatternsWithMetadata.map(p => p.category))].sort();
  1158. // Clear existing options except "All"
  1159. categorySelect.innerHTML = '<option value="all">All Folders</option>';
  1160. // Add category options
  1161. categories.forEach(category => {
  1162. if (category) {
  1163. const option = document.createElement('option');
  1164. option.value = category;
  1165. // Display friendly names for full paths
  1166. if (category === 'root') {
  1167. option.textContent = 'Root Folder';
  1168. } else {
  1169. // For full paths, show the path but make it more readable
  1170. const parts = category
  1171. .split('/')
  1172. .map(part => part.charAt(0).toUpperCase() + part.slice(1).replace('_', ' '));
  1173. // Check if we're on a small screen and truncate if necessary
  1174. const isSmallScreen = window.innerWidth < 640; // sm breakpoint
  1175. let displayName;
  1176. if (isSmallScreen && parts.length > 1) {
  1177. // On small screens, show only the last part with "..." if nested
  1178. displayName = '...' + parts[parts.length - 1];
  1179. } else {
  1180. // Full path with separators
  1181. displayName = parts.join(' › ');
  1182. }
  1183. option.textContent = displayName;
  1184. }
  1185. categorySelect.appendChild(option);
  1186. }
  1187. });
  1188. }
  1189. // Handle sort field change
  1190. function handleBrowseSortFieldChange() {
  1191. const sortFieldSelect = document.getElementById('browseSortFieldSelect');
  1192. if (sortFieldSelect) {
  1193. currentSort.field = sortFieldSelect.value;
  1194. applyPatternsFilteringAndSorting();
  1195. }
  1196. }
  1197. // Handle sort direction toggle
  1198. function handleBrowseSortDirectionToggle() {
  1199. currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
  1200. applyPatternsFilteringAndSorting();
  1201. }
  1202. // Handle category filter change
  1203. function handleBrowseCategoryFilterChange() {
  1204. const categorySelect = document.getElementById('browseCategoryFilterSelect');
  1205. if (categorySelect) {
  1206. currentFilters.category = categorySelect.value;
  1207. applyPatternsFilteringAndSorting();
  1208. }
  1209. }
  1210. // Enable sort controls when metadata is loaded
  1211. function enableSortControls() {
  1212. const browseSortFieldSelect = document.getElementById('browseSortFieldSelect');
  1213. const browseSortDirectionBtn = document.getElementById('browseSortDirectionBtn');
  1214. const browseCategoryFilterSelect = document.getElementById('browseCategoryFilterSelect');
  1215. if (browseSortFieldSelect) {
  1216. browseSortFieldSelect.disabled = false;
  1217. // Ensure dropdown shows the current sort field
  1218. browseSortFieldSelect.value = currentSort.field;
  1219. }
  1220. if (browseSortDirectionBtn) {
  1221. browseSortDirectionBtn.disabled = false;
  1222. browseSortDirectionBtn.classList.remove('opacity-50', 'cursor-not-allowed');
  1223. browseSortDirectionBtn.classList.add('hover:bg-gray-200');
  1224. browseSortDirectionBtn.title = 'Toggle sort direction';
  1225. // Update direction icon
  1226. const sortDirectionIcon = document.getElementById('browseSortDirectionIcon');
  1227. if (sortDirectionIcon) {
  1228. sortDirectionIcon.textContent = currentSort.direction === 'asc' ? 'arrow_upward' : 'arrow_downward';
  1229. }
  1230. }
  1231. if (browseCategoryFilterSelect) {
  1232. browseCategoryFilterSelect.disabled = false;
  1233. }
  1234. // Only apply sorting if user has changed from defaults or if patterns need to be refreshed
  1235. // If already showing patterns with default sort (name, asc), don't reorder unnecessarily
  1236. if (currentSort.field !== 'name' || currentSort.direction !== 'asc' || currentFilters.category !== 'all') {
  1237. applyPatternsFilteringAndSorting();
  1238. }
  1239. }
  1240. // Filter patterns by category
  1241. function filterPatternsByCategory(category) {
  1242. // TODO: Implement category filtering logic
  1243. logMessage(`Filtering patterns by category: ${category}`, LOG_TYPE.INFO);
  1244. }
  1245. // Filter patterns by tag
  1246. function filterPatternsByTag(tag) {
  1247. // TODO: Implement tag filtering logic
  1248. logMessage(`Filtering patterns by tag: ${tag}`, LOG_TYPE.INFO);
  1249. }
  1250. // Initialize the patterns page
  1251. document.addEventListener('DOMContentLoaded', async () => {
  1252. try {
  1253. logMessage('Initializing patterns page...', LOG_TYPE.DEBUG);
  1254. // Initialize IndexedDB preview cache (shared with playlists page)
  1255. await initPreviewCacheDB();
  1256. // Setup upload event handlers
  1257. setupUploadEventHandlers();
  1258. // Initialize intersection observer for lazy loading
  1259. initPreviewObserver();
  1260. // Setup search functionality
  1261. const searchInput = document.getElementById('patternSearch');
  1262. const searchButton = document.getElementById('searchButton');
  1263. const cacheAllButton = document.getElementById('cacheAllButton');
  1264. if (searchInput && searchButton) {
  1265. // Search on button click
  1266. searchButton.addEventListener('click', () => {
  1267. searchPatterns(searchInput.value.trim());
  1268. });
  1269. // Search on Enter key
  1270. searchInput.addEventListener('keypress', (e) => {
  1271. if (e.key === 'Enter') {
  1272. searchPatterns(searchInput.value.trim());
  1273. }
  1274. });
  1275. // Clear search when input is empty
  1276. searchInput.addEventListener('input', (e) => {
  1277. if (e.target.value.trim() === '') {
  1278. searchPatterns('');
  1279. }
  1280. });
  1281. }
  1282. // Sort and filter controls for browse page
  1283. const browseSortFieldSelect = document.getElementById('browseSortFieldSelect');
  1284. const browseSortDirectionBtn = document.getElementById('browseSortDirectionBtn');
  1285. const browseCategoryFilterSelect = document.getElementById('browseCategoryFilterSelect');
  1286. if (browseSortFieldSelect) {
  1287. browseSortFieldSelect.addEventListener('change', handleBrowseSortFieldChange);
  1288. }
  1289. if (browseSortDirectionBtn) {
  1290. browseSortDirectionBtn.addEventListener('click', handleBrowseSortDirectionToggle);
  1291. }
  1292. if (browseCategoryFilterSelect) {
  1293. browseCategoryFilterSelect.addEventListener('change', handleBrowseCategoryFilterChange);
  1294. }
  1295. // Setup cache all button - now triggers the modal
  1296. if (cacheAllButton) {
  1297. cacheAllButton.addEventListener('click', () => {
  1298. // Always show the modal when manually clicked, using forceShow parameter
  1299. if (typeof showCacheAllPrompt === 'function') {
  1300. showCacheAllPrompt(true); // true = forceShow
  1301. } else {
  1302. // Fallback if function not available
  1303. const modal = document.getElementById('cacheAllPromptModal');
  1304. if (modal) {
  1305. modal.classList.remove('hidden');
  1306. modal.dataset.manuallyTriggered = 'true';
  1307. }
  1308. }
  1309. });
  1310. }
  1311. // Load favorites first, then patterns
  1312. await loadFavorites();
  1313. await loadPatterns();
  1314. logMessage('Patterns page initialized successfully', LOG_TYPE.SUCCESS);
  1315. } catch (error) {
  1316. logMessage(`Error during initialization: ${error.message}`, LOG_TYPE.ERROR);
  1317. }
  1318. });
  1319. // Cancel any pending requests when navigating away from the page
  1320. window.addEventListener('beforeunload', () => {
  1321. if (metadataAbortController) {
  1322. metadataAbortController.abort();
  1323. metadataAbortController = null;
  1324. logMessage('Cancelled pending metadata request due to navigation', LOG_TYPE.DEBUG);
  1325. }
  1326. });
  1327. // Also handle page visibility changes (switching tabs, minimizing, etc.)
  1328. document.addEventListener('visibilitychange', () => {
  1329. if (document.hidden && metadataAbortController) {
  1330. // Cancel long-running requests when page is hidden
  1331. metadataAbortController.abort();
  1332. metadataAbortController = null;
  1333. logMessage('Cancelled pending metadata request due to page hidden', LOG_TYPE.DEBUG);
  1334. }
  1335. });
  1336. function updateCurrentlyPlayingUI(status) {
  1337. // Get all required DOM elements once
  1338. const container = document.getElementById('currently-playing-container');
  1339. const fileNameElement = document.getElementById('currently-playing-file');
  1340. const progressBar = document.getElementById('play_progress');
  1341. const progressText = document.getElementById('play_progress_text');
  1342. const pausePlayButton = document.getElementById('pausePlayCurrent');
  1343. const speedDisplay = document.getElementById('current_speed_display');
  1344. const speedInput = document.getElementById('speedInput');
  1345. // Check if all required elements exist
  1346. if (!container || !fileNameElement || !progressBar || !progressText) {
  1347. console.log('Required DOM elements not found:', {
  1348. container: !!container,
  1349. fileNameElement: !!fileNameElement,
  1350. progressBar: !!progressBar,
  1351. progressText: !!progressText
  1352. });
  1353. setTimeout(() => updateCurrentlyPlayingUI(status), 100);
  1354. return;
  1355. }
  1356. // Update container visibility based on status
  1357. if (status.current_file && status.is_running) {
  1358. document.body.classList.add('playing');
  1359. container.style.display = 'flex';
  1360. } else {
  1361. document.body.classList.remove('playing');
  1362. container.style.display = 'none';
  1363. }
  1364. // Update file name display
  1365. if (status.current_file) {
  1366. const fileName = normalizeFilePath(status.current_file);
  1367. fileNameElement.textContent = fileName;
  1368. } else {
  1369. fileNameElement.textContent = 'No pattern playing';
  1370. }
  1371. // Update next file display
  1372. const nextFileElement = document.getElementById('next-file');
  1373. if (nextFileElement) {
  1374. if (status.playlist && status.playlist.next_file) {
  1375. const nextFileName = normalizeFilePath(status.playlist.next_file);
  1376. nextFileElement.textContent = `(Next: ${nextFileName})`;
  1377. nextFileElement.style.display = 'block';
  1378. } else {
  1379. nextFileElement.style.display = 'none';
  1380. }
  1381. }
  1382. // Update speed display and input if they exist
  1383. if (status.speed) {
  1384. if (speedDisplay) {
  1385. speedDisplay.textContent = `Current Speed: ${status.speed}`;
  1386. }
  1387. if (speedInput) {
  1388. speedInput.value = status.speed;
  1389. }
  1390. }
  1391. // Update pattern preview if it's a new pattern
  1392. // ... existing code ...
  1393. }
  1394. // Setup upload event handlers
  1395. function setupUploadEventHandlers() {
  1396. // Upload file input handler - supports multiple files
  1397. document.getElementById('patternFileInput').addEventListener('change', async function(e) {
  1398. const files = e.target.files;
  1399. if (!files || files.length === 0) return;
  1400. const totalFiles = files.length;
  1401. const fileArray = Array.from(files);
  1402. let successCount = 0;
  1403. let failCount = 0;
  1404. // Show initial progress message
  1405. showStatusMessage(`Uploading ${totalFiles} pattern${totalFiles > 1 ? 's' : ''}...`);
  1406. try {
  1407. // Upload files sequentially to avoid overwhelming the server
  1408. for (let i = 0; i < fileArray.length; i++) {
  1409. const file = fileArray[i];
  1410. try {
  1411. const formData = new FormData();
  1412. formData.append('file', file);
  1413. const response = await fetch('/upload_theta_rho', {
  1414. method: 'POST',
  1415. body: formData
  1416. });
  1417. const result = await response.json();
  1418. if (result.success) {
  1419. successCount++;
  1420. // Invalidate pattern files cache to include new file
  1421. invalidatePatternFilesCache();
  1422. // Clear any existing cache for this pattern to ensure fresh loading
  1423. const newPatternPath = `custom_patterns/${file.name}`;
  1424. previewCache.delete(newPatternPath);
  1425. logMessage(`Successfully uploaded: ${file.name}`, LOG_TYPE.SUCCESS);
  1426. } else {
  1427. failCount++;
  1428. logMessage(`Failed to upload ${file.name}: ${result.error}`, LOG_TYPE.ERROR);
  1429. }
  1430. } catch (fileError) {
  1431. failCount++;
  1432. logMessage(`Error uploading ${file.name}: ${fileError.message}`, LOG_TYPE.ERROR);
  1433. }
  1434. // Update progress
  1435. const progress = i + 1;
  1436. showStatusMessage(`Uploading patterns... ${progress}/${totalFiles}`);
  1437. }
  1438. // Show final result
  1439. if (successCount > 0) {
  1440. const message = failCount > 0
  1441. ? `Uploaded ${successCount} pattern${successCount > 1 ? 's' : ''}, ${failCount} failed`
  1442. : `Successfully uploaded ${successCount} pattern${successCount > 1 ? 's' : ''}`;
  1443. showStatusMessage(message);
  1444. // Add a small delay to allow backend preview generation to complete
  1445. await new Promise(resolve => setTimeout(resolve, 1000));
  1446. // Refresh the pattern list (cache already invalidated above)
  1447. await loadPatterns();
  1448. // Trigger preview loading for newly uploaded patterns
  1449. setTimeout(() => {
  1450. fileArray.forEach(file => {
  1451. const newPatternPath = `custom_patterns/${file.name}`;
  1452. const newPatternCard = document.querySelector(`[data-pattern="${newPatternPath}"]`);
  1453. if (newPatternCard) {
  1454. const previewContainer = newPatternCard.querySelector('.pattern-preview');
  1455. if (previewContainer) {
  1456. previewContainer.dataset.retryCount = '0';
  1457. previewContainer.dataset.hasTriedIndividual = 'false';
  1458. previewContainer.dataset.isNewUpload = 'true';
  1459. addPatternToBatch(newPatternPath, previewContainer);
  1460. }
  1461. }
  1462. });
  1463. }, 500);
  1464. } else {
  1465. showStatusMessage(`Failed to upload all ${totalFiles} pattern${totalFiles > 1 ? 's' : ''}`, 'error');
  1466. }
  1467. // Clear the file input
  1468. e.target.value = '';
  1469. } catch (error) {
  1470. console.error('Error during batch upload:', error);
  1471. showStatusMessage(`Error uploading patterns: ${error.message}`, 'error');
  1472. }
  1473. });
  1474. // Pattern deletion handler
  1475. const deleteModal = document.getElementById('deleteConfirmModal');
  1476. if (deleteModal) {
  1477. const confirmBtn = deleteModal.querySelector('#confirmDeleteBtn');
  1478. const cancelBtn = deleteModal.querySelector('#cancelDeleteBtn');
  1479. if (confirmBtn) {
  1480. confirmBtn.addEventListener('click', async () => {
  1481. const patternToDelete = confirmBtn.dataset.pattern;
  1482. if (patternToDelete) {
  1483. await deletePattern(patternToDelete);
  1484. // Refresh after deletion (cache invalidated in deletePattern)
  1485. await loadPatterns();
  1486. }
  1487. deleteModal.classList.add('hidden');
  1488. });
  1489. }
  1490. if (cancelBtn) {
  1491. cancelBtn.addEventListener('click', () => {
  1492. deleteModal.classList.add('hidden');
  1493. });
  1494. }
  1495. }
  1496. }
  1497. // Cache all pattern previews
  1498. async function cacheAllPreviews() {
  1499. const cacheAllButton = document.getElementById('cacheAllButton');
  1500. if (!cacheAllButton) return;
  1501. try {
  1502. // Disable button and show loading state
  1503. cacheAllButton.disabled = true;
  1504. // Get current cache size
  1505. const currentSize = await getPreviewCacheSize();
  1506. const maxSize = MAX_CACHE_SIZE_BYTES || (200 * 1024 * 1024); // 200MB default
  1507. if (currentSize > maxSize) {
  1508. // Clear cache if it's too large
  1509. await clearPreviewCache();
  1510. // Also clear progress since we're starting fresh
  1511. localStorage.removeItem(CACHE_PROGRESS_KEY);
  1512. localStorage.removeItem(CACHE_TIMESTAMP_KEY);
  1513. }
  1514. // Get all patterns that aren't cached yet
  1515. const uncachedPatterns = allPatterns.filter(pattern => !previewCache.has(pattern));
  1516. if (uncachedPatterns.length === 0) {
  1517. showStatusMessage('All patterns are already cached!', 'info');
  1518. return;
  1519. }
  1520. // Check for existing progress
  1521. let startIndex = 0;
  1522. const savedProgress = localStorage.getItem(CACHE_PROGRESS_KEY);
  1523. const savedTimestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
  1524. if (savedProgress && savedTimestamp) {
  1525. const progressAge = Date.now() - parseInt(savedTimestamp);
  1526. if (progressAge < CACHE_PROGRESS_EXPIRY) {
  1527. const lastCachedPattern = savedProgress;
  1528. const lastIndex = uncachedPatterns.findIndex(p => p === lastCachedPattern);
  1529. if (lastIndex !== -1) {
  1530. startIndex = lastIndex + 1;
  1531. showStatusMessage('Resuming from previous progress...', 'info');
  1532. }
  1533. } else {
  1534. // Clear expired progress
  1535. localStorage.removeItem(CACHE_PROGRESS_KEY);
  1536. localStorage.removeItem(CACHE_TIMESTAMP_KEY);
  1537. }
  1538. }
  1539. // Process patterns in smaller batches to avoid overwhelming the server
  1540. const BATCH_SIZE = 10;
  1541. const remainingPatterns = uncachedPatterns.slice(startIndex);
  1542. const totalBatches = Math.ceil(remainingPatterns.length / BATCH_SIZE);
  1543. for (let i = 0; i < totalBatches; i++) {
  1544. const batchStart = i * BATCH_SIZE;
  1545. const batchEnd = Math.min(batchStart + BATCH_SIZE, remainingPatterns.length);
  1546. const batchPatterns = remainingPatterns.slice(batchStart, batchEnd);
  1547. // Update button text with progress
  1548. const overallProgress = Math.round(((startIndex + batchStart + BATCH_SIZE) / uncachedPatterns.length) * 100);
  1549. cacheAllButton.innerHTML = `
  1550. <div class="bg-white bg-opacity-30 rounded-full h-4 w-4 flex items-center justify-center">
  1551. <div class="bg-white rounded-full h-2 w-2"></div>
  1552. </div>
  1553. <span>Caching ${overallProgress}%</span>
  1554. `;
  1555. try {
  1556. const response = await fetch('/preview_thr_batch', {
  1557. method: 'POST',
  1558. headers: { 'Content-Type': 'application/json' },
  1559. body: JSON.stringify({ file_names: batchPatterns })
  1560. });
  1561. if (response.ok) {
  1562. const results = await response.json();
  1563. // Cache each preview
  1564. for (const [pattern, data] of Object.entries(results)) {
  1565. if (data && !data.error && data.image_data) {
  1566. previewCache.set(pattern, data);
  1567. await savePreviewToCache(pattern, data);
  1568. // Save progress after each successful pattern
  1569. localStorage.setItem(CACHE_PROGRESS_KEY, pattern);
  1570. localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
  1571. }
  1572. }
  1573. }
  1574. } catch (error) {
  1575. logMessage(`Error caching batch ${i + 1}: ${error.message}`, LOG_TYPE.ERROR);
  1576. // Don't clear progress on error - allows resuming from last successful pattern
  1577. }
  1578. // Small delay between batches to prevent overwhelming the server
  1579. await new Promise(resolve => setTimeout(resolve, 100));
  1580. }
  1581. // Clear progress after successful completion
  1582. localStorage.removeItem(CACHE_PROGRESS_KEY);
  1583. localStorage.removeItem(CACHE_TIMESTAMP_KEY);
  1584. // Show success message
  1585. showStatusMessage('All pattern previews have been cached!', 'success');
  1586. } catch (error) {
  1587. logMessage(`Error caching previews: ${error.message}`, LOG_TYPE.ERROR);
  1588. showStatusMessage('Failed to cache all previews. Click again to resume.', 'error');
  1589. } finally {
  1590. // Reset button state
  1591. if (cacheAllButton) {
  1592. cacheAllButton.disabled = false;
  1593. cacheAllButton.innerHTML = `
  1594. <span class="material-icons text-sm">cached</span>
  1595. Cache All Previews
  1596. `;
  1597. }
  1598. }
  1599. }
  1600. // Open animated preview modal
  1601. async function openAnimatedPreview(pattern) {
  1602. try {
  1603. const modal = document.getElementById('animatedPreviewModal');
  1604. const title = document.getElementById('animatedPreviewTitle');
  1605. const canvas = document.getElementById('animatedPreviewCanvas');
  1606. const ctx = canvas.getContext('2d');
  1607. // Set title
  1608. title.textContent = pattern.replace('.thr', '').split('/').pop();
  1609. // Show modal
  1610. modal.classList.remove('hidden');
  1611. // Load pattern coordinates
  1612. const response = await fetch('/get_theta_rho_coordinates', {
  1613. method: 'POST',
  1614. headers: { 'Content-Type': 'application/json' },
  1615. body: JSON.stringify({ file_name: pattern })
  1616. });
  1617. if (!response.ok) {
  1618. throw new Error(`HTTP error! status: ${response.status}`);
  1619. }
  1620. const data = await response.json();
  1621. if (data.error) {
  1622. throw new Error(data.error);
  1623. }
  1624. animatedPreviewData = data.coordinates;
  1625. // Setup canvas
  1626. setupAnimatedPreviewCanvas(ctx);
  1627. // Setup controls
  1628. setupAnimatedPreviewControls();
  1629. // Draw initial state
  1630. drawAnimatedPreview(ctx, 0);
  1631. // Auto-play the animation
  1632. setTimeout(() => {
  1633. playAnimation();
  1634. }, 100); // Small delay to ensure everything is set up
  1635. } catch (error) {
  1636. logMessage(`Error opening animated preview: ${error.message}`, LOG_TYPE.ERROR);
  1637. showStatusMessage('Failed to load pattern for animation', 'error');
  1638. }
  1639. }
  1640. // Setup animated preview canvas
  1641. function setupAnimatedPreviewCanvas(ctx) {
  1642. const canvas = ctx.canvas;
  1643. const size = canvas.width;
  1644. const center = size / 2;
  1645. const scale = (size / 2) - 30; // Slightly smaller to account for border
  1646. // Clear canvas with white background
  1647. ctx.fillStyle = '#ffffff';
  1648. ctx.fillRect(0, 0, size, size);
  1649. // Set drawing style for ultra-high quality lines
  1650. ctx.strokeStyle = '#000000';
  1651. ctx.lineWidth = 1; // Thinner line for higher resolution
  1652. ctx.lineCap = 'round';
  1653. ctx.lineJoin = 'round';
  1654. // Enable high quality rendering
  1655. ctx.imageSmoothingEnabled = true;
  1656. ctx.imageSmoothingQuality = 'high';
  1657. }
  1658. // Setup animated preview controls
  1659. function setupAnimatedPreviewControls() {
  1660. const modal = document.getElementById('animatedPreviewModal');
  1661. const closeBtn = document.getElementById('closeAnimatedPreview');
  1662. const playPauseBtn = document.getElementById('playPauseBtn');
  1663. const resetBtn = document.getElementById('resetBtn');
  1664. const speedSlider = document.getElementById('speedSlider');
  1665. const speedValue = document.getElementById('speedValue');
  1666. const progressSlider = document.getElementById('progressSlider');
  1667. const progressValue = document.getElementById('progressValue');
  1668. const canvas = document.getElementById('animatedPreviewCanvas');
  1669. const playPauseOverlay = document.getElementById('playPauseOverlay');
  1670. // Set responsive canvas size with ultra-high-DPI support
  1671. const setCanvasSize = () => {
  1672. const container = canvas.parentElement;
  1673. const modal = document.getElementById('animatedPreviewModal');
  1674. if (!container || !modal) return;
  1675. // Calculate available viewport space
  1676. const viewportWidth = window.innerWidth;
  1677. const viewportHeight = window.innerHeight;
  1678. // Calculate modal content area (95vh max height - header - padding)
  1679. const modalMaxHeight = viewportHeight * 0.95;
  1680. const headerHeight = 80; // Approximate header height with padding
  1681. const modalPadding = 48; // Modal padding (p-6 = 24px each side)
  1682. const availableHeight = modalMaxHeight - headerHeight - modalPadding;
  1683. // Calculate available width (max-w-4xl = 896px, but respect viewport)
  1684. const modalMaxWidth = Math.min(896, viewportWidth - 32); // Account for modal margin
  1685. const availableWidth = modalMaxWidth - modalPadding;
  1686. // Calculate ideal canvas size (use 80% of available space as requested)
  1687. const targetHeight = availableHeight * 0.8;
  1688. const targetWidth = availableWidth * 0.8;
  1689. // Use the smaller dimension to maintain square aspect ratio
  1690. let idealSize = Math.min(targetWidth, targetHeight);
  1691. // Cap at reasonable maximum and minimum
  1692. idealSize = Math.min(idealSize, 800); // Maximum size cap
  1693. idealSize = Math.max(idealSize, 200); // Minimum size
  1694. const displaySize = idealSize;
  1695. console.log('Canvas sizing:', {
  1696. viewport: `${viewportWidth}x${viewportHeight}`,
  1697. availableModal: `${availableWidth}x${availableHeight}`,
  1698. target80pct: `${targetWidth}x${targetHeight}`,
  1699. finalSize: displaySize
  1700. });
  1701. // Get device pixel ratio and multiply by 2 for higher resolution
  1702. const pixelRatio = (window.devicePixelRatio || 1) * 2;
  1703. // Set the display size (CSS pixels) - use pixels, not percentage
  1704. canvas.style.width = displaySize + 'px';
  1705. canvas.style.height = displaySize + 'px';
  1706. // Set the actual canvas size (device pixels) - increased resolution
  1707. canvas.width = displaySize * pixelRatio;
  1708. canvas.height = displaySize * pixelRatio;
  1709. // Scale the context to match the increased pixel ratio
  1710. const ctx = canvas.getContext('2d', { alpha: false }); // Disable alpha for better performance
  1711. ctx.scale(pixelRatio, pixelRatio);
  1712. // Enable high quality rendering
  1713. ctx.imageSmoothingEnabled = true;
  1714. ctx.imageSmoothingQuality = 'high';
  1715. // Redraw with new size
  1716. if (animatedPreviewData) {
  1717. setupAnimatedPreviewCanvas(ctx);
  1718. drawAnimatedPreview(ctx, currentProgress / 100);
  1719. }
  1720. };
  1721. // Set initial size
  1722. setCanvasSize();
  1723. // Handle window resize with debouncing
  1724. let resizeTimeout;
  1725. window.addEventListener('resize', () => {
  1726. clearTimeout(resizeTimeout);
  1727. resizeTimeout = setTimeout(setCanvasSize, 16); // ~60fps update rate
  1728. });
  1729. // Close modal
  1730. closeBtn.onclick = closeAnimatedPreview;
  1731. modal.onclick = (e) => {
  1732. if (e.target === modal) closeAnimatedPreview();
  1733. };
  1734. // Play/Pause button
  1735. playPauseBtn.onclick = toggleAnimation;
  1736. // Reset button
  1737. resetBtn.onclick = resetAnimation;
  1738. // Speed slider
  1739. speedSlider.oninput = (e) => {
  1740. animationSpeed = parseFloat(e.target.value);
  1741. speedValue.textContent = `${animationSpeed}x`;
  1742. };
  1743. // Progress slider
  1744. progressSlider.oninput = (e) => {
  1745. currentProgress = parseFloat(e.target.value);
  1746. progressValue.textContent = `${currentProgress.toFixed(1)}%`;
  1747. drawAnimatedPreview(canvas.getContext('2d'), currentProgress / 100);
  1748. if (isPlaying) {
  1749. // Pause animation when manually adjusting progress
  1750. toggleAnimation();
  1751. }
  1752. };
  1753. // Canvas click to play/pause
  1754. canvas.onclick = () => {
  1755. playPauseOverlay.style.opacity = '1';
  1756. setTimeout(() => {
  1757. playPauseOverlay.style.opacity = '0';
  1758. }, 200);
  1759. toggleAnimation();
  1760. };
  1761. // Keyboard shortcuts
  1762. document.addEventListener('keydown', (e) => {
  1763. if (modal.classList.contains('hidden')) return;
  1764. switch(e.code) {
  1765. case 'Space':
  1766. e.preventDefault();
  1767. toggleAnimation();
  1768. break;
  1769. case 'Escape':
  1770. closeAnimatedPreview();
  1771. break;
  1772. case 'ArrowLeft':
  1773. e.preventDefault();
  1774. currentProgress = Math.max(0, currentProgress - 5);
  1775. updateProgressUI();
  1776. drawAnimatedPreview(canvas.getContext('2d'), currentProgress / 100);
  1777. break;
  1778. case 'ArrowRight':
  1779. e.preventDefault();
  1780. currentProgress = Math.min(100, currentProgress + 5);
  1781. updateProgressUI();
  1782. drawAnimatedPreview(canvas.getContext('2d'), currentProgress / 100);
  1783. break;
  1784. }
  1785. });
  1786. }
  1787. // Draw animated preview
  1788. function drawAnimatedPreview(ctx, progress) {
  1789. if (!animatedPreviewData || animatedPreviewData.length === 0) return;
  1790. const canvas = ctx.canvas;
  1791. const pixelRatio = (window.devicePixelRatio || 1) * 2; // Match the increased ratio
  1792. const displayWidth = parseInt(canvas.style.width);
  1793. const displayHeight = parseInt(canvas.style.height);
  1794. const center = (canvas.width / pixelRatio) / 2;
  1795. const scale = ((canvas.width / pixelRatio) / 2) - 30;
  1796. // Clear canvas with white background
  1797. ctx.clearRect(0, 0, canvas.width, canvas.height);
  1798. // Calculate how many points to draw
  1799. const totalPoints = animatedPreviewData.length;
  1800. const pointsToDraw = Math.floor(totalPoints * progress);
  1801. if (pointsToDraw < 2) return;
  1802. // Draw the path with ultra-high quality settings
  1803. ctx.beginPath();
  1804. ctx.strokeStyle = '#000000';
  1805. ctx.lineWidth = 1; // Thinner line for higher resolution
  1806. ctx.lineCap = 'round';
  1807. ctx.lineJoin = 'round';
  1808. // Ensure sub-pixel alignment for ultra-high resolution
  1809. for (let i = 0; i < pointsToDraw; i++) {
  1810. const [theta, rho] = animatedPreviewData[i];
  1811. // Round to nearest 0.25 for even more precise lines
  1812. // Mirror both X and Y coordinates
  1813. const x = Math.round((center + rho * scale * Math.cos(theta)) * 4) / 4; // Changed minus to plus
  1814. const y = Math.round((center + rho * scale * Math.sin(theta)) * 4) / 4;
  1815. if (i === 0) {
  1816. ctx.moveTo(x, y);
  1817. } else {
  1818. ctx.lineTo(x, y);
  1819. }
  1820. }
  1821. ctx.stroke();
  1822. // Draw current position dot
  1823. if (pointsToDraw > 0) {
  1824. const [currentTheta, currentRho] = animatedPreviewData[pointsToDraw - 1];
  1825. const currentX = Math.round((center + currentRho * scale * Math.cos(currentTheta)) * 4) / 4; // Changed minus to plus
  1826. const currentY = Math.round((center + currentRho * scale * Math.sin(currentTheta)) * 4) / 4;
  1827. // Draw a filled circle at current position with anti-aliasing
  1828. ctx.fillStyle = '#ff4444'; // Red dot
  1829. ctx.beginPath();
  1830. ctx.arc(currentX, currentY, 6, 0, 2 * Math.PI); // Increased dot size
  1831. ctx.fill();
  1832. // Add a subtle white border
  1833. ctx.strokeStyle = '#ffffff';
  1834. ctx.lineWidth = 1.5;
  1835. ctx.stroke();
  1836. }
  1837. }
  1838. // Toggle animation play/pause
  1839. function toggleAnimation() {
  1840. if (isPlaying) {
  1841. pauseAnimation();
  1842. } else {
  1843. playAnimation();
  1844. }
  1845. }
  1846. // Play animation
  1847. function playAnimation() {
  1848. if (!animatedPreviewData) return;
  1849. isPlaying = true;
  1850. lastTimestamp = performance.now();
  1851. // Update UI
  1852. const playPauseBtn = document.getElementById('playPauseBtn');
  1853. const playPauseBtnIcon = document.getElementById('playPauseBtnIcon');
  1854. const playPauseBtnText = document.getElementById('playPauseBtnText');
  1855. if (playPauseBtnIcon) playPauseBtnIcon.textContent = 'pause';
  1856. if (playPauseBtnText) playPauseBtnText.textContent = 'Pause';
  1857. // Start animation loop
  1858. animationFrameId = requestAnimationFrame(animate);
  1859. }
  1860. // Pause animation
  1861. function pauseAnimation() {
  1862. isPlaying = false;
  1863. // Update UI
  1864. const playPauseBtn = document.getElementById('playPauseBtn');
  1865. const playPauseBtnIcon = document.getElementById('playPauseBtnIcon');
  1866. const playPauseBtnText = document.getElementById('playPauseBtnText');
  1867. if (playPauseBtnIcon) playPauseBtnIcon.textContent = 'play_arrow';
  1868. if (playPauseBtnText) playPauseBtnText.textContent = 'Play';
  1869. // Cancel animation frame
  1870. if (animationFrameId) {
  1871. cancelAnimationFrame(animationFrameId);
  1872. animationFrameId = null;
  1873. }
  1874. }
  1875. // Animation loop
  1876. function animate(timestamp) {
  1877. if (!isPlaying) return;
  1878. const deltaTime = timestamp - lastTimestamp;
  1879. const progressIncrement = (deltaTime / 1000) * animationSpeed * 2.0; // Much faster base speed
  1880. currentProgress = Math.min(100, currentProgress + progressIncrement);
  1881. // Update UI
  1882. updateProgressUI();
  1883. // Draw frame
  1884. const canvas = document.getElementById('animatedPreviewCanvas');
  1885. if (canvas) {
  1886. drawAnimatedPreview(canvas.getContext('2d'), currentProgress / 100);
  1887. }
  1888. // Continue animation
  1889. if (currentProgress < 100) {
  1890. lastTimestamp = timestamp;
  1891. animationFrameId = requestAnimationFrame(animate);
  1892. } else {
  1893. // Animation complete
  1894. pauseAnimation();
  1895. }
  1896. }
  1897. // Reset animation
  1898. function resetAnimation() {
  1899. pauseAnimation();
  1900. currentProgress = 0;
  1901. updateProgressUI();
  1902. const canvas = document.getElementById('animatedPreviewCanvas');
  1903. drawAnimatedPreview(canvas.getContext('2d'), 0);
  1904. }
  1905. // Update progress UI
  1906. function updateProgressUI() {
  1907. const progressSlider = document.getElementById('progressSlider');
  1908. const progressValue = document.getElementById('progressValue');
  1909. progressSlider.value = currentProgress;
  1910. progressValue.textContent = `${currentProgress.toFixed(1)}%`;
  1911. }
  1912. // Close animated preview
  1913. function closeAnimatedPreview() {
  1914. pauseAnimation();
  1915. const modal = document.getElementById('animatedPreviewModal');
  1916. modal.classList.add('hidden');
  1917. // Clear data
  1918. animatedPreviewData = null;
  1919. currentProgress = 0;
  1920. animationSpeed = 1;
  1921. // Reset UI
  1922. const speedSlider = document.getElementById('speedSlider');
  1923. const speedValue = document.getElementById('speedValue');
  1924. const progressSlider = document.getElementById('progressSlider');
  1925. const progressValue = document.getElementById('progressValue');
  1926. speedSlider.value = 1;
  1927. speedValue.textContent = '1x';
  1928. progressSlider.value = 0;
  1929. progressValue.textContent = '0%';
  1930. }
  1931. // Global set to track favorite patterns
  1932. let favoritePatterns = new Set();
  1933. // Make favoritePatterns available globally for other scripts
  1934. window.favoritePatterns = favoritePatterns;
  1935. // Load favorites from server on page load
  1936. async function loadFavorites() {
  1937. try {
  1938. const response = await fetch('/get_playlist?name=Favorites');
  1939. if (response.ok) {
  1940. const playlist = await response.json();
  1941. favoritePatterns = new Set(playlist.files);
  1942. window.favoritePatterns = favoritePatterns; // Keep window reference updated
  1943. updateAllHeartIcons();
  1944. }
  1945. } catch (error) {
  1946. // Favorites playlist doesn't exist yet - that's OK
  1947. console.debug('Favorites playlist not found, will create when needed');
  1948. }
  1949. }
  1950. // Toggle favorite status
  1951. async function toggleFavorite(pattern) {
  1952. const heartIcon = document.getElementById('heart-' + pattern.replace(/[^a-zA-Z0-9]/g, '_'));
  1953. if (!heartIcon) return;
  1954. try {
  1955. if (favoritePatterns.has(pattern)) {
  1956. // Remove from favorites
  1957. await removeFromFavorites(pattern);
  1958. favoritePatterns.delete(pattern);
  1959. heartIcon.textContent = 'favorite_border';
  1960. heartIcon.className = 'material-icons text-lg text-gray-400 hover:text-red-500 transition-colors';
  1961. // Make heart only visible on hover when not favorited
  1962. heartIcon.parentElement.className = heartIcon.parentElement.className.replace('opacity-100', 'opacity-0 group-hover:opacity-100');
  1963. showStatusMessage('Removed from favorites', 'success');
  1964. } else {
  1965. // Add to favorites
  1966. await addToFavorites(pattern);
  1967. favoritePatterns.add(pattern);
  1968. heartIcon.textContent = 'favorite';
  1969. heartIcon.className = 'material-icons text-lg text-red-500 hover:text-red-600 transition-colors';
  1970. // Make heart permanently visible when favorited
  1971. heartIcon.parentElement.className = heartIcon.parentElement.className.replace('opacity-0 group-hover:opacity-100', 'opacity-100');
  1972. showStatusMessage('Added to favorites', 'success');
  1973. }
  1974. } catch (error) {
  1975. console.error('Error toggling favorite:', error);
  1976. showStatusMessage('Failed to update favorites', 'error');
  1977. }
  1978. }
  1979. // Add pattern to favorites playlist
  1980. async function addToFavorites(pattern) {
  1981. try {
  1982. // First, check if Favorites playlist exists
  1983. const checkResponse = await fetch('/get_playlist?name=Favorites');
  1984. if (checkResponse.ok) {
  1985. // Playlist exists, add to it
  1986. const response = await fetch('/add_to_playlist', {
  1987. method: 'POST',
  1988. headers: {
  1989. 'Content-Type': 'application/json',
  1990. },
  1991. body: JSON.stringify({
  1992. playlist_name: 'Favorites',
  1993. pattern: pattern
  1994. })
  1995. });
  1996. if (!response.ok) {
  1997. throw new Error('Failed to add to favorites playlist');
  1998. }
  1999. } else {
  2000. // Playlist doesn't exist, create it with this pattern
  2001. const response = await fetch('/create_playlist', {
  2002. method: 'POST',
  2003. headers: {
  2004. 'Content-Type': 'application/json',
  2005. },
  2006. body: JSON.stringify({
  2007. playlist_name: 'Favorites',
  2008. files: [pattern]
  2009. })
  2010. });
  2011. if (!response.ok) {
  2012. throw new Error('Failed to create favorites playlist');
  2013. }
  2014. }
  2015. } catch (error) {
  2016. throw new Error(`Failed to add to favorites: ${error.message}`);
  2017. }
  2018. }
  2019. // Remove pattern from favorites playlist
  2020. async function removeFromFavorites(pattern) {
  2021. try {
  2022. // Get current favorites playlist
  2023. const getResponse = await fetch('/get_playlist?name=Favorites');
  2024. if (!getResponse.ok) return; // No favorites playlist
  2025. const currentFavorites = await getResponse.json();
  2026. const updatedFavorites = currentFavorites.files.filter(p => p !== pattern);
  2027. // Update the playlist
  2028. const updateResponse = await fetch('/modify_playlist', {
  2029. method: 'POST',
  2030. headers: {
  2031. 'Content-Type': 'application/json',
  2032. },
  2033. body: JSON.stringify({
  2034. playlist_name: 'Favorites',
  2035. files: updatedFavorites
  2036. })
  2037. });
  2038. if (!updateResponse.ok) {
  2039. throw new Error('Failed to update favorites playlist');
  2040. }
  2041. } catch (error) {
  2042. throw new Error(`Failed to remove from favorites: ${error.message}`);
  2043. }
  2044. }
  2045. // Update all heart icons based on current favorites
  2046. function updateAllHeartIcons() {
  2047. favoritePatterns.forEach(pattern => {
  2048. const heartIcon = document.getElementById('heart-' + pattern.replace(/[^a-zA-Z0-9]/g, '_'));
  2049. if (heartIcon) {
  2050. heartIcon.textContent = 'favorite';
  2051. heartIcon.className = 'material-icons text-lg text-red-500 hover:text-red-600 transition-colors';
  2052. // Make heart permanently visible when favorited
  2053. heartIcon.parentElement.className = heartIcon.parentElement.className.replace('opacity-0 group-hover:opacity-100', 'opacity-100');
  2054. }
  2055. });
  2056. }