diff --git a/README.md b/README.md index 47e170b5..376bf25b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web

+ Documentation | Website | Discord | Twitter | diff --git a/docker-compose.yml b/docker-compose.yml index 874e48d6..91b72428 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: #build: #context: . #dockerfile: server/Dockerfile - image: getmaxun/maxun-backend:v0.0.9 + image: getmaxun/maxun-backend:v0.0.10 ports: - "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}" env_file: .env @@ -70,7 +70,7 @@ services: #build: #context: . #dockerfile: Dockerfile - image: getmaxun/maxun-frontend:v0.0.5 + image: getmaxun/maxun-frontend:v0.0.7 ports: - "${FRONTEND_PORT:-5173}:${FRONTEND_PORT:-5173}" env_file: .env diff --git a/maxun-core/package.json b/maxun-core/package.json index 7c92d08e..ddaaa510 100644 --- a/maxun-core/package.json +++ b/maxun-core/package.json @@ -1,6 +1,6 @@ { "name": "maxun-core", - "version": "0.0.7", + "version": "0.0.8", "description": "Core package for Maxun, responsible for data extraction", "main": "build/index.js", "typings": "build/index.d.ts", diff --git a/maxun-core/src/browserSide/scraper.js b/maxun-core/src/browserSide/scraper.js index 0c5d74ac..f6b53da2 100644 --- a/maxun-core/src/browserSide/scraper.js +++ b/maxun-core/src/browserSide/scraper.js @@ -188,69 +188,201 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, * @param {Object.} lists The named lists of HTML elements. * @returns {Array.>} */ - window.scrapeSchema = function (lists) { + window.scrapeSchema = function(lists) { + // Utility functions remain the same function omap(object, f, kf = (x) => x) { return Object.fromEntries( Object.entries(object) - .map(([k, v]) => [kf(k), f(v)]), + .map(([k, v]) => [kf(k), f(v)]), ); } function ofilter(object, f) { return Object.fromEntries( Object.entries(object) - .filter(([k, v]) => f(k, v)), + .filter(([k, v]) => f(k, v)), ); } - function getSeedKey(listObj) { - const maxLength = Math.max(...Object.values(omap(listObj, (x) => document.querySelectorAll(x.selector).length))); - return Object.keys(ofilter(listObj, (_, v) => document.querySelectorAll(v.selector).length === maxLength))[0]; + function findAllElements(config) { + // Regular DOM query if no special delimiters + if (!config.selector.includes('>>') && !config.selector.includes(':>>')) { + return Array.from(document.querySelectorAll(config.selector)); + } + + // First handle iframe traversal if present + if (config.selector.includes(':>>')) { + const parts = config.selector.split(':>>').map(s => s.trim()); + let currentElements = [document]; + + // Traverse through each part of the selector + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const nextElements = []; + const isLast = i === parts.length - 1; + + for (const element of currentElements) { + try { + // For document or iframe document + const doc = element.contentDocument || element || element.contentWindow?.document; + if (!doc) continue; + + // Query elements in current context + const found = Array.from(doc.querySelectorAll(part)); + + if (isLast) { + // If it's the last part, keep all matching elements + nextElements.push(...found); + } else { + // If not last, only keep iframes for next iteration + const iframes = found.filter(el => el.tagName === 'IFRAME'); + nextElements.push(...iframes); + } + } catch (error) { + console.warn('Cannot access iframe content:', error, { + part, + element, + index: i + }); + } + } + + if (nextElements.length === 0) { + console.warn('No elements found for part:', part, 'at depth:', i); + return []; + } + currentElements = nextElements; + } + + return currentElements; + } + + // Handle shadow DOM traversal + if (config.selector.includes('>>')) { + const parts = config.selector.split('>>').map(s => s.trim()); + let currentElements = [document]; + + for (const part of parts) { + const nextElements = []; + for (const element of currentElements) { + // Try regular DOM first + const found = Array.from(element.querySelectorAll(part)); + + // Then check shadow roots + for (const foundEl of found) { + if (foundEl.shadowRoot) { + nextElements.push(foundEl.shadowRoot); + } else { + nextElements.push(foundEl); + } + } + } + currentElements = nextElements; + } + return currentElements.filter(el => !(el instanceof ShadowRoot)); + } + + return []; } + // Modified to handle iframe context for URL resolution + function getElementValue(element, attribute) { + if (!element) return null; + + // Get the base URL for resolving relative URLs + const baseURL = element.ownerDocument?.location?.href || window.location.origin; + + switch (attribute) { + case 'href': { + const relativeHref = element.getAttribute('href'); + return relativeHref ? new URL(relativeHref, baseURL).href : null; + } + case 'src': { + const relativeSrc = element.getAttribute('src'); + return relativeSrc ? new URL(relativeSrc, baseURL).href : null; + } + case 'innerText': + return element.innerText?.trim(); + case 'textContent': + return element.textContent?.trim(); + default: + return element.getAttribute(attribute) || element.innerText?.trim(); + } + } + + // Rest of the functions remain largely the same + function getSeedKey(listObj) { + const maxLength = Math.max(...Object.values( + omap(listObj, (x) => findAllElements(x).length) + )); + return Object.keys( + ofilter(listObj, (_, v) => findAllElements(v).length === maxLength) + )[0]; + } + + // Find minimal bounding elements function getMBEs(elements) { return elements.map((element) => { let candidate = element; const isUniqueChild = (e) => elements - .filter((elem) => e.parentNode?.contains(elem)) + .filter((elem) => { + // Handle both iframe and shadow DOM boundaries + const sameContext = elem.getRootNode() === e.getRootNode() && + elem.ownerDocument === e.ownerDocument; + return sameContext && e.parentNode?.contains(elem); + }) .length === 1; - + while (candidate && isUniqueChild(candidate)) { candidate = candidate.parentNode; } - + return candidate; }); } const seedName = getSeedKey(lists); - const seedElements = Array.from(document.querySelectorAll(lists[seedName].selector)); + const seedElements = findAllElements(lists[seedName]); const MBEs = getMBEs(seedElements); - - return MBEs.map((mbe) => omap( - lists, - ({ selector, attribute }, key) => { - const elem = Array.from(document.querySelectorAll(selector)).find((elem) => mbe.contains(elem)); - if (!elem) return undefined; - - switch (attribute) { - case 'href': - const relativeHref = elem.getAttribute('href'); - return relativeHref ? new URL(relativeHref, window.location.origin).href : null; - case 'src': - const relativeSrc = elem.getAttribute('src'); - return relativeSrc ? new URL(relativeSrc, window.location.origin).href : null; - case 'innerText': - return elem.innerText; - case 'textContent': - return elem.textContent; - default: - return elem.innerText; - } - }, - (key) => key // Use the original key in the output + + const mbeResults = MBEs.map((mbe) => omap( + lists, + (config) => { + const elem = findAllElements(config) + .find((elem) => mbe.contains(elem)); + + return elem ? getElementValue(elem, config.attribute) : undefined; + }, + (key) => key )) || []; - } + + // If MBE approach didn't find all elements, try independent scraping + if (mbeResults.some(result => Object.values(result).some(v => v === undefined))) { + // Fall back to independent scraping + const results = []; + const foundElements = new Map(); + + // Find all elements for each selector + Object.entries(lists).forEach(([key, config]) => { + const elements = findAllElements(config); + foundElements.set(key, elements); + }); + + // Create result objects for each found element + foundElements.forEach((elements, key) => { + elements.forEach((element, index) => { + if (!results[index]) { + results[index] = {}; + } + results[index][key] = getElementValue(element, lists[key].attribute); + }); + }); + + return results.filter(result => Object.keys(result).length > 0); + } + + return mbeResults; + }; /** * Scrapes multiple lists of similar items based on a template item. @@ -262,108 +394,275 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, * @returns {Array.>} Array of arrays of scraped items, one sub-array per list */ window.scrapeList = async function ({ listSelector, fields, limit = 10 }) { - // Helper function to extract values from elements + // Enhanced query function to handle both iframe and shadow DOM + const queryElement = (rootElement, selector) => { + if (!selector.includes('>>') && !selector.includes(':>>')) { + return rootElement.querySelector(selector); + } + + const parts = selector.split(/(?:>>|:>>)/).map(part => part.trim()); + let currentElement = rootElement; + + for (let i = 0; i < parts.length; i++) { + if (!currentElement) return null; + + // Handle iframe traversal + if (currentElement.tagName === 'IFRAME') { + try { + const iframeDoc = currentElement.contentDocument || currentElement.contentWindow.document; + currentElement = iframeDoc.querySelector(parts[i]); + continue; + } catch (e) { + console.warn('Cannot access iframe content:', e); + return null; + } + } + + // Try regular DOM first + let nextElement = currentElement.querySelector(parts[i]); + + // Try shadow DOM if not found + if (!nextElement && currentElement.shadowRoot) { + nextElement = currentElement.shadowRoot.querySelector(parts[i]); + } + + // Check children's shadow roots if still not found + if (!nextElement) { + const children = Array.from(currentElement.children || []); + for (const child of children) { + if (child.shadowRoot) { + nextElement = child.shadowRoot.querySelector(parts[i]); + if (nextElement) break; + } + } + } + + currentElement = nextElement; + } + + return currentElement; + }; + + // Enhanced query all function for both contexts + const queryElementAll = (rootElement, selector) => { + if (!selector.includes('>>') && !selector.includes(':>>')) { + return rootElement.querySelectorAll(selector); + } + + const parts = selector.split(/(?:>>|:>>)/).map(part => part.trim()); + let currentElements = [rootElement]; + + for (const part of parts) { + const nextElements = []; + + for (const element of currentElements) { + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + try { + const iframeDoc = element.contentDocument || element.contentWindow.document; + nextElements.push(...iframeDoc.querySelectorAll(part)); + } catch (e) { + console.warn('Cannot access iframe content:', e); + continue; + } + } else { + // Regular DOM elements + if (element.querySelectorAll) { + nextElements.push(...element.querySelectorAll(part)); + } + + // Shadow DOM elements + if (element.shadowRoot) { + nextElements.push(...element.shadowRoot.querySelectorAll(part)); + } + + // Check children's shadow roots + const children = Array.from(element.children || []); + for (const child of children) { + if (child.shadowRoot) { + nextElements.push(...child.shadowRoot.querySelectorAll(part)); + } + } + } + } + + currentElements = nextElements; + } + + return currentElements; + }; + + // Enhanced value extraction with context awareness function extractValue(element, attribute) { - if (!element) return null; - - if (attribute === 'innerText') { - return element.innerText.trim(); - } else if (attribute === 'innerHTML') { - return element.innerHTML.trim(); - } else if (attribute === 'src' || attribute === 'href') { - const attrValue = element.getAttribute(attribute); - return attrValue ? new URL(attrValue, window.location.origin).href : null; - } - return element.getAttribute(attribute); + if (!element) return null; + + // Get context-aware base URL + const baseURL = element.ownerDocument?.location?.href || window.location.origin; + + // Check shadow root first + if (element.shadowRoot) { + const shadowContent = element.shadowRoot.textContent; + if (shadowContent?.trim()) { + return shadowContent.trim(); + } + } + + if (attribute === 'innerText') { + return element.innerText.trim(); + } else if (attribute === 'innerHTML') { + return element.innerHTML.trim(); + } else if (attribute === 'src' || attribute === 'href') { + const attrValue = element.getAttribute(attribute); + return attrValue ? new URL(attrValue, baseURL).href : null; + } + return element.getAttribute(attribute); } - // Helper function to find table ancestors + // Enhanced table ancestor finding with context support function findTableAncestor(element) { - let currentElement = element; - const MAX_DEPTH = 5; - let depth = 0; - - while (currentElement && depth < MAX_DEPTH) { - if (currentElement.tagName === 'TD') { - return { type: 'TD', element: currentElement }; - } else if (currentElement.tagName === 'TR') { - return { type: 'TR', element: currentElement }; - } - currentElement = currentElement.parentElement; - depth++; - } - return null; + let currentElement = element; + const MAX_DEPTH = 5; + let depth = 0; + + while (currentElement && depth < MAX_DEPTH) { + // Handle shadow DOM + if (currentElement.getRootNode() instanceof ShadowRoot) { + currentElement = currentElement.getRootNode().host; + continue; + } + + if (currentElement.tagName === 'TD') { + return { type: 'TD', element: currentElement }; + } else if (currentElement.tagName === 'TR') { + return { type: 'TR', element: currentElement }; + } + + // Handle iframe crossing + if (currentElement.tagName === 'IFRAME') { + try { + currentElement = currentElement.contentDocument.body; + } catch (e) { + return null; + } + } else { + currentElement = currentElement.parentElement; + } + depth++; + } + return null; } + // Helper function to get cell index function getCellIndex(td) { - let index = 0; - let sibling = td; - while (sibling = sibling.previousElementSibling) { - index++; - } - return index; + if (td.getRootNode() instanceof ShadowRoot) { + const shadowRoot = td.getRootNode(); + const allCells = Array.from(shadowRoot.querySelectorAll('td')); + return allCells.indexOf(td); + } + + let index = 0; + let sibling = td; + while (sibling = sibling.previousElementSibling) { + index++; + } + return index; } + // Helper function to check for TH elements function hasThElement(row, tableFields) { - for (const [label, { selector }] of Object.entries(tableFields)) { - const element = row.querySelector(selector); - if (element) { - let current = element; - while (current && current !== row) { - if (current.tagName === 'TH') { - return true; - } - current = current.parentElement; - } - } - } - return false; + for (const [_, { selector }] of Object.entries(tableFields)) { + const element = queryElement(row, selector); + if (element) { + let current = element; + while (current && current !== row) { + if (current.getRootNode() instanceof ShadowRoot) { + current = current.getRootNode().host; + continue; + } + + if (current.tagName === 'TH') return true; + + if (current.tagName === 'IFRAME') { + try { + current = current.contentDocument.body; + } catch (e) { + break; + } + } else { + current = current.parentElement; + } + } + } + } + return false; } + // Helper function to filter rows function filterRowsBasedOnTag(rows, tableFields) { for (const row of rows) { if (hasThElement(row, tableFields)) { return rows; } } - return rows.filter(row => row.getElementsByTagName('TH').length === 0); + // Include shadow DOM in TH search + return rows.filter(row => { + const directTH = row.getElementsByTagName('TH').length === 0; + const shadowTH = row.shadowRoot ? + row.shadowRoot.querySelector('th') === null : true; + return directTH && shadowTH; + }); } + // Class similarity comparison functions function calculateClassSimilarity(classList1, classList2) { - const set1 = new Set(classList1); - const set2 = new Set(classList2); - - // Calculate intersection - const intersection = new Set([...set1].filter(x => set2.has(x))); - - // Calculate union - const union = new Set([...set1, ...set2]); - - // Return Jaccard similarity coefficient - return intersection.size / union.size; - } + const set1 = new Set(classList1); + const set2 = new Set(classList2); + const intersection = new Set([...set1].filter(x => set2.has(x))); + const union = new Set([...set1, ...set2]); + return intersection.size / union.size; + } - // New helper function to find elements with similar classes + // Enhanced similar elements finding with context support function findSimilarElements(baseElement, similarityThreshold = 0.7) { const baseClasses = Array.from(baseElement.classList); - if (baseClasses.length === 0) return []; + + const allElements = []; - const potentialElements = document.getElementsByTagName(baseElement.tagName); + // Get elements from main document + allElements.push(...document.getElementsByTagName(baseElement.tagName)); - return Array.from(potentialElements).filter(element => { - if (element === baseElement) return false; - - const similarity = calculateClassSimilarity( - baseClasses, - Array.from(element.classList) - ); - - return similarity >= similarityThreshold; + // Get elements from shadow DOM + if (baseElement.getRootNode() instanceof ShadowRoot) { + const shadowHost = baseElement.getRootNode().host; + allElements.push(...shadowHost.getElementsByTagName(baseElement.tagName)); + } + + // Get elements from iframes + const iframes = document.getElementsByTagName('iframe'); + for (const iframe of iframes) { + try { + const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; + allElements.push(...iframeDoc.getElementsByTagName(baseElement.tagName)); + } catch (e) { + console.warn('Cannot access iframe content:', e); + } + } + + return allElements.filter(element => { + if (element === baseElement) return false; + const similarity = calculateClassSimilarity( + baseClasses, + Array.from(element.classList) + ); + return similarity >= similarityThreshold; }); } - let containers = Array.from(document.querySelectorAll(listSelector)); + // Main scraping logic with context support + let containers = queryElementAll(document, listSelector); + containers = Array.from(containers); + if (containers.length === 0) return []; if (limit > 1 && containers.length === 1) { @@ -374,115 +673,157 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, const newContainers = similarContainers.filter(container => !container.matches(listSelector) ); - containers = [...containers, ...newContainers]; } } - // Initialize arrays to store field classifications for each container const containerFields = containers.map(() => ({ - tableFields: {}, - nonTableFields: {} + tableFields: {}, + nonTableFields: {} })); - // Analyze field types for each container + // Classify fields containers.forEach((container, containerIndex) => { - for (const [label, field] of Object.entries(fields)) { - const sampleElement = container.querySelector(field.selector); - - if (sampleElement) { - const ancestor = findTableAncestor(sampleElement); - if (ancestor) { - containerFields[containerIndex].tableFields[label] = { - ...field, - tableContext: ancestor.type, - cellIndex: ancestor.type === 'TD' ? getCellIndex(ancestor.element) : -1 - }; - } else { - containerFields[containerIndex].nonTableFields[label] = field; - } + for (const [label, field] of Object.entries(fields)) { + const sampleElement = queryElement(container, field.selector); + + if (sampleElement) { + const ancestor = findTableAncestor(sampleElement); + if (ancestor) { + containerFields[containerIndex].tableFields[label] = { + ...field, + tableContext: ancestor.type, + cellIndex: ancestor.type === 'TD' ? getCellIndex(ancestor.element) : -1 + }; } else { containerFields[containerIndex].nonTableFields[label] = field; } + } else { + containerFields[containerIndex].nonTableFields[label] = field; } + } }); const tableData = []; const nonTableData = []; - - // Process table fields across all containers + + // Process table data with both iframe and shadow DOM support for (let containerIndex = 0; containerIndex < containers.length; containerIndex++) { const container = containers[containerIndex]; const { tableFields } = containerFields[containerIndex]; if (Object.keys(tableFields).length > 0) { - const firstField = Object.values(tableFields)[0]; - const firstElement = container.querySelector(firstField.selector); - let tableContext = firstElement; - - while (tableContext && tableContext.tagName !== 'TABLE' && tableContext !== container) { - tableContext = tableContext.parentElement; - } - - if (tableContext) { - const rows = Array.from(tableContext.getElementsByTagName('TR')); - const processedRows = filterRowsBasedOnTag(rows, tableFields); - - for (let rowIndex = 0; rowIndex < Math.min(processedRows.length, limit); rowIndex++) { - const record = {}; - const currentRow = processedRows[rowIndex]; - - for (const [label, { selector, attribute, cellIndex }] of Object.entries(tableFields)) { - let element = null; - - if (cellIndex >= 0) { - const td = currentRow.children[cellIndex]; - if (td) { - element = td.querySelector(selector); - - if (!element && selector.split(">").pop().includes('td:nth-child')) { - element = td; + const firstField = Object.values(tableFields)[0]; + const firstElement = queryElement(container, firstField.selector); + let tableContext = firstElement; + + // Find table context including both iframe and shadow DOM + while (tableContext && tableContext.tagName !== 'TABLE' && tableContext !== container) { + if (tableContext.getRootNode() instanceof ShadowRoot) { + tableContext = tableContext.getRootNode().host; + continue; + } + + if (tableContext.tagName === 'IFRAME') { + try { + tableContext = tableContext.contentDocument.body; + } catch (e) { + break; } + } else { + tableContext = tableContext.parentElement; + } + } - if (!element) { - const tagOnlySelector = selector.split('.')[0]; - element = td.querySelector(tagOnlySelector); + if (tableContext) { + // Get rows from all contexts + const rows = []; + + // Get rows from regular DOM + rows.push(...tableContext.getElementsByTagName('TR')); + + // Get rows from shadow DOM + if (tableContext.shadowRoot) { + rows.push(...tableContext.shadowRoot.getElementsByTagName('TR')); + } + + // Get rows from iframes + if (tableContext.tagName === 'IFRAME') { + try { + const iframeDoc = tableContext.contentDocument || tableContext.contentWindow.document; + rows.push(...iframeDoc.getElementsByTagName('TR')); + } catch (e) { + console.warn('Cannot access iframe rows:', e); } + } + + const processedRows = filterRowsBasedOnTag(rows, tableFields); + + for (let rowIndex = 0; rowIndex < Math.min(processedRows.length, limit); rowIndex++) { + const record = {}; + const currentRow = processedRows[rowIndex]; - if (!element) { - let currentElement = td; - while (currentElement && currentElement.children.length > 0) { - let foundContentChild = false; - for (const child of currentElement.children) { - if (extractValue(child, attribute)) { - currentElement = child; - foundContentChild = true; - break; + for (const [label, { selector, attribute, cellIndex }] of Object.entries(tableFields)) { + let element = null; + + if (cellIndex >= 0) { + // Get TD element considering both contexts + let td = currentRow.children[cellIndex]; + + // Check shadow DOM for td + if (!td && currentRow.shadowRoot) { + const shadowCells = currentRow.shadowRoot.children; + if (shadowCells && shadowCells.length > cellIndex) { + td = shadowCells[cellIndex]; } } - if (!foundContentChild) break; - } - element = currentElement; - } - } - } else { - element = currentRow.querySelector(selector); - } - - if (element) { - record[label] = extractValue(element, attribute); - } - } + + if (td) { + element = queryElement(td, selector); + + if (!element && selector.split(/(?:>>|:>>)/).pop().includes('td:nth-child')) { + element = td; + } - if (Object.keys(record).length > 0) { - tableData.push(record); - } + if (!element) { + const tagOnlySelector = selector.split('.')[0]; + element = queryElement(td, tagOnlySelector); + } + + if (!element) { + let currentElement = td; + while (currentElement && currentElement.children.length > 0) { + let foundContentChild = false; + for (const child of currentElement.children) { + if (extractValue(child, attribute)) { + currentElement = child; + foundContentChild = true; + break; + } + } + if (!foundContentChild) break; + } + element = currentElement; + } + } + } else { + element = queryElement(currentRow, selector); + } + + if (element) { + record[label] = extractValue(element, attribute); + } + } + + if (Object.keys(record).length > 0) { + tableData.push(record); + } + } } - } } } - - // Process non-table fields across all containers + + // Process non-table data with both contexts support for (let containerIndex = 0; containerIndex < containers.length; containerIndex++) { if (nonTableData.length >= limit) break; @@ -490,26 +831,28 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3, const { nonTableFields } = containerFields[containerIndex]; if (Object.keys(nonTableFields).length > 0) { - const record = {}; + const record = {}; - for (const [label, { selector, attribute }] of Object.entries(nonTableFields)) { - const element = container.querySelector(selector); - - if (element) { - record[label] = extractValue(element, attribute); - } - } + for (const [label, { selector, attribute }] of Object.entries(nonTableFields)) { + // Get the last part of the selector after any context delimiter + const relativeSelector = selector.split(/(?:>>|:>>)/).slice(-1)[0]; + const element = queryElement(container, relativeSelector); - if (Object.keys(record).length > 0) { - nonTableData.push(record); - } - } + if (element) { + record[label] = extractValue(element, attribute); + } + } + + if (Object.keys(record).length > 0) { + nonTableData.push(record); + } + } } // Merge and limit the results const scrapedData = [...tableData, ...nonTableData]; return scrapedData; -}; + }; /** * Gets all children of the elements matching the listSelector, diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index c581954d..e09ac5d5 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -403,7 +403,7 @@ export default class Interpreter extends EventEmitter { await this.options.serializableCallback(scrapeResults); }, - scrapeSchema: async (schema: Record) => { + scrapeSchema: async (schema: Record) => { await this.ensureScriptsLoaded(page); const scrapeResult = await page.evaluate((schemaObj) => window.scrapeSchema(schemaObj), schema); @@ -663,11 +663,42 @@ export default class Interpreter extends EventEmitter { if (isApplicable) { return actionId; } + } } + + private removeShadowSelectors(workflow: Workflow) { + for (let actionId = workflow.length - 1; actionId >= 0; actionId--) { + const step = workflow[actionId]; + + // Check if step has where and selectors + if (step.where && Array.isArray(step.where.selectors)) { + // Filter out selectors that contain ">>" + step.where.selectors = step.where.selectors.filter(selector => !selector.includes('>>')); + } + } + + return workflow; + } + + private removeSpecialSelectors(workflow: Workflow) { + for (let actionId = workflow.length - 1; actionId >= 0; actionId--) { + const step = workflow[actionId]; + + if (step.where && Array.isArray(step.where.selectors)) { + // Filter out if selector has EITHER ":>>" OR ">>" + step.where.selectors = step.where.selectors.filter(selector => + !(selector.includes(':>>') || selector.includes('>>')) + ); + } + } + + return workflow; } private async runLoop(p: Page, workflow: Workflow) { - const workflowCopy: Workflow = JSON.parse(JSON.stringify(workflow)); + let workflowCopy: Workflow = JSON.parse(JSON.stringify(workflow)); + + workflowCopy = this.removeSpecialSelectors(workflowCopy); // apply ad-blocker to the current page try { diff --git a/package.json b/package.json index e89f13de..36062666 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxun", - "version": "0.0.5", + "version": "0.0.6", "author": "Maxun", "license": "AGPL-3.0-or-later", "dependencies": { @@ -44,9 +44,10 @@ "joi": "^17.6.0", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", + "lodash": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "maxun-core": "^0.0.7", + "maxun-core": "^0.0.8", "minio": "^8.0.1", "moment-timezone": "^0.5.45", "node-cron": "^3.0.3", @@ -66,6 +67,7 @@ "react-transition-group": "^4.4.2", "sequelize": "^6.37.3", "sequelize-typescript": "^2.1.6", + "sharp": "^0.33.5", "socket.io": "^4.4.1", "socket.io-client": "^4.4.1", "styled-components": "^5.3.3", @@ -97,6 +99,7 @@ "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.13", "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.14", "@types/loglevel": "^1.6.3", "@types/node": "22.7.9", "@types/node-cron": "^3.0.11", diff --git a/perf/performance.ts b/perf/performance.ts new file mode 100644 index 00000000..c50ef850 --- /dev/null +++ b/perf/performance.ts @@ -0,0 +1,181 @@ +// Frontend Performance Monitoring +export class FrontendPerformanceMonitor { + private metrics: { + fps: number[]; + memoryUsage: MemoryInfo[]; + renderTime: number[]; + eventLatency: number[]; + }; + private lastFrameTime: number; + private frameCount: number; + + constructor() { + this.metrics = { + fps: [], + memoryUsage: [], + renderTime: [], + eventLatency: [], + }; + this.lastFrameTime = performance.now(); + this.frameCount = 0; + + // Start monitoring + this.startMonitoring(); + } + + private startMonitoring(): void { + // Monitor FPS + const measureFPS = () => { + const currentTime = performance.now(); + const elapsed = currentTime - this.lastFrameTime; + this.frameCount++; + + if (elapsed >= 1000) { // Calculate FPS every second + const fps = Math.round((this.frameCount * 1000) / elapsed); + this.metrics.fps.push(fps); + this.frameCount = 0; + this.lastFrameTime = currentTime; + } + requestAnimationFrame(measureFPS); + }; + requestAnimationFrame(measureFPS); + + // Monitor Memory Usage + if (window.performance && (performance as any).memory) { + setInterval(() => { + const memory = (performance as any).memory; + this.metrics.memoryUsage.push({ + usedJSHeapSize: memory.usedJSHeapSize, + totalJSHeapSize: memory.totalJSHeapSize, + timestamp: Date.now() + }); + }, 1000); + } + } + + // Monitor Canvas Render Time + public measureRenderTime(renderFunction: () => void): void { + const startTime = performance.now(); + renderFunction(); + const endTime = performance.now(); + this.metrics.renderTime.push(endTime - startTime); + } + + // Monitor Event Latency + public measureEventLatency(event: MouseEvent | KeyboardEvent): void { + const latency = performance.now() - event.timeStamp; + this.metrics.eventLatency.push(latency); + } + + // Get Performance Report + public getPerformanceReport(): PerformanceReport { + return { + averageFPS: this.calculateAverage(this.metrics.fps), + averageRenderTime: this.calculateAverage(this.metrics.renderTime), + averageEventLatency: this.calculateAverage(this.metrics.eventLatency), + memoryTrend: this.getMemoryTrend(), + lastMemoryUsage: this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1] + }; + } + + private calculateAverage(array: number[]): number { + return array.length ? array.reduce((a, b) => a + b) / array.length : 0; + } + + private getMemoryTrend(): MemoryTrend { + if (this.metrics.memoryUsage.length < 2) return 'stable'; + const latest = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1]; + const previous = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 2]; + const change = latest.usedJSHeapSize - previous.usedJSHeapSize; + if (change > 1000000) return 'increasing'; // 1MB threshold + if (change < -1000000) return 'decreasing'; + return 'stable'; + } +} + +// Backend Performance Monitoring +export class BackendPerformanceMonitor { + private metrics: { + screenshotTimes: number[]; + emitTimes: number[]; + memoryUsage: NodeJS.MemoryUsage[]; + }; + + constructor() { + this.metrics = { + screenshotTimes: [], + emitTimes: [], + memoryUsage: [] + }; + this.startMonitoring(); + } + + private startMonitoring(): void { + // Monitor Memory Usage + setInterval(() => { + this.metrics.memoryUsage.push(process.memoryUsage()); + }, 1000); + } + + public async measureScreenshotPerformance( + makeScreenshot: () => Promise + ): Promise { + const startTime = process.hrtime(); + await makeScreenshot(); + const [seconds, nanoseconds] = process.hrtime(startTime); + this.metrics.screenshotTimes.push(seconds * 1000 + nanoseconds / 1000000); + } + + public measureEmitPerformance(emitFunction: () => void): void { + const startTime = process.hrtime(); + emitFunction(); + const [seconds, nanoseconds] = process.hrtime(startTime); + this.metrics.emitTimes.push(seconds * 1000 + nanoseconds / 1000000); + } + + public getPerformanceReport(): BackendPerformanceReport { + return { + averageScreenshotTime: this.calculateAverage(this.metrics.screenshotTimes), + averageEmitTime: this.calculateAverage(this.metrics.emitTimes), + currentMemoryUsage: this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1], + memoryTrend: this.getMemoryTrend() + }; + } + + private calculateAverage(array: number[]): number { + return array.length ? array.reduce((a, b) => a + b) / array.length : 0; + } + + private getMemoryTrend(): MemoryTrend { + if (this.metrics.memoryUsage.length < 2) return 'stable'; + const latest = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1]; + const previous = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 2]; + const change = latest.heapUsed - previous.heapUsed; + if (change > 1000000) return 'increasing'; + if (change < -1000000) return 'decreasing'; + return 'stable'; + } +} + +interface MemoryInfo { + usedJSHeapSize: number; + totalJSHeapSize: number; + timestamp: number; +} + +type MemoryTrend = 'increasing' | 'decreasing' | 'stable'; + +interface PerformanceReport { + averageFPS: number; + averageRenderTime: number; + averageEventLatency: number; + memoryTrend: MemoryTrend; + lastMemoryUsage: MemoryInfo; +} + +interface BackendPerformanceReport { + averageScreenshotTime: number; + averageEmitTime: number; + currentMemoryUsage: NodeJS.MemoryUsage; + memoryTrend: MemoryTrend; +} \ No newline at end of file diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index 2c45d146..8ff4f601 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -9,6 +9,8 @@ import { chromium } from 'playwright-extra'; import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import { PlaywrightBlocker } from '@cliqz/adblocker-playwright'; import fetch from 'cross-fetch'; +import { throttle } from 'lodash'; +import sharp from 'sharp'; import logger from '../../logger'; import { InterpreterSettings, RemoteBrowserOptions } from "../../types"; @@ -16,8 +18,30 @@ import { WorkflowGenerator } from "../../workflow-management/classes/Generator"; import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter"; import { getDecryptedProxyConfig } from '../../routes/proxy'; import { getInjectableScript } from 'idcac-playwright'; + chromium.use(stealthPlugin()); +const MEMORY_CONFIG = { + gcInterval: 60000, // 1 minute + maxHeapSize: 2048 * 1024 * 1024, // 2GB + heapUsageThreshold: 0.85 // 85% +}; + +const SCREENCAST_CONFIG: { + format: "jpeg" | "png"; + maxWidth: number; + maxHeight: number; + targetFPS: number; + compressionQuality: number; + maxQueueSize: number; +} = { + format: 'jpeg', + maxWidth: 900, + maxHeight: 400, + targetFPS: 30, + compressionQuality: 0.8, + maxQueueSize: 2 +}; /** * This class represents a remote browser instance. @@ -78,6 +102,11 @@ export class RemoteBrowser { */ public interpreter: WorkflowInterpreter; + + private screenshotQueue: Buffer[] = []; + private isProcessingScreenshot = false; + private screencastInterval: NodeJS.Timeout | null = null + /** * Initializes a new instances of the {@link Generator} and {@link WorkflowInterpreter} classes and * assigns the socket instance everywhere. @@ -90,6 +119,46 @@ export class RemoteBrowser { this.generator = new WorkflowGenerator(socket); } + private initializeMemoryManagement(): void { + setInterval(() => { + const memoryUsage = process.memoryUsage(); + const heapUsageRatio = memoryUsage.heapUsed / MEMORY_CONFIG.maxHeapSize; + + if (heapUsageRatio > MEMORY_CONFIG.heapUsageThreshold) { + logger.warn('High memory usage detected, triggering cleanup'); + this.performMemoryCleanup(); + } + + // Clear screenshot queue if it's too large + if (this.screenshotQueue.length > SCREENCAST_CONFIG.maxQueueSize) { + this.screenshotQueue = this.screenshotQueue.slice(-SCREENCAST_CONFIG.maxQueueSize); + } + }, MEMORY_CONFIG.gcInterval); + } + + private async performMemoryCleanup(): Promise { + this.screenshotQueue = []; + this.isProcessingScreenshot = false; + + if (global.gc) { + global.gc(); + } + + // Reset CDP session if needed + if (this.client) { + try { + await this.stopScreencast(); + this.client = null; + if (this.currentPage) { + this.client = await this.currentPage.context().newCDPSession(this.currentPage); + await this.startScreencast(); + } + } catch (error) { + logger.error('Error resetting CDP session:', error); + } + } + } + /** * Normalizes URLs to prevent navigation loops while maintaining consistent format */ @@ -157,7 +226,7 @@ export class RemoteBrowser { 'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.62 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:118.0) Gecko/20100101 Firefox/118.0', ]; - + return userAgents[Math.floor(Math.random() * userAgents.length)]; } @@ -178,7 +247,7 @@ export class RemoteBrowser { "--disable-extensions", "--no-sandbox", "--disable-dev-shm-usage", - ], + ], })); const proxyConfig = await getDecryptedProxyConfig(userId); let proxyOptions: { server: string, username?: string, password?: string } = { server: '' }; @@ -251,11 +320,11 @@ export class RemoteBrowser { this.client = await this.currentPage.context().newCDPSession(this.currentPage); await blocker.disableBlockingInPage(this.currentPage); console.log('Adblocker initialized'); - } catch (error: any) { + } catch (error: any) { console.warn('Failed to initialize adblocker, continuing without it:', error.message); // Still need to set up the CDP session even if blocker fails this.client = await this.currentPage.context().newCDPSession(this.currentPage); - } + } }; /** @@ -319,7 +388,7 @@ export class RemoteBrowser { return; } this.client.on('Page.screencastFrame', ({ data: base64, sessionId }) => { - this.emitScreenshot(base64) + this.emitScreenshot(Buffer.from(base64, 'base64')) setTimeout(async () => { try { if (!this.client) { @@ -339,16 +408,49 @@ export class RemoteBrowser { * If an interpretation was running it will be stopped. * @returns {Promise} */ - public switchOff = async (): Promise => { - await this.interpreter.stopInterpretation(); - if (this.browser) { - await this.stopScreencast(); - await this.browser.close(); - } else { - logger.log('error', 'Browser wasn\'t initialized'); - logger.log('error', 'Switching off the browser failed'); + public async switchOff(): Promise { + try { + await this.interpreter.stopInterpretation(); + + if (this.screencastInterval) { + clearInterval(this.screencastInterval); + } + + if (this.client) { + await this.stopScreencast(); + } + + if (this.browser) { + await this.browser.close(); + } + + this.screenshotQueue = []; + //this.performanceMonitor.reset(); + + } catch (error) { + logger.error('Error during browser shutdown:', error); } - }; + } + + private async optimizeScreenshot(screenshot: Buffer): Promise { + try { + return await sharp(screenshot) + .jpeg({ + quality: Math.round(SCREENCAST_CONFIG.compressionQuality * 100), + progressive: true + }) + .resize({ + width: SCREENCAST_CONFIG.maxWidth, + height: SCREENCAST_CONFIG.maxHeight, + fit: 'inside', + withoutEnlargement: true + }) + .toBuffer(); + } catch (error) { + logger.error('Screenshot optimization failed:', error); + return screenshot; + } + } /** * Makes and emits a single screenshot to the client side. @@ -358,7 +460,7 @@ export class RemoteBrowser { try { const screenshot = await this.currentPage?.screenshot(); if (screenshot) { - this.emitScreenshot(screenshot.toString('base64')); + this.emitScreenshot(screenshot); } } catch (e) { const { message } = e as Error; @@ -490,37 +592,85 @@ export class RemoteBrowser { * Should be called only once after the browser is fully initialized. * @returns {Promise} */ - private startScreencast = async (): Promise => { + private async startScreencast(): Promise { if (!this.client) { - logger.log('warn', 'client is not initialized'); + logger.warn('Client is not initialized'); return; } - await this.client.send('Page.startScreencast', { format: 'jpeg', quality: 75 }); - logger.log('info', `Browser started with screencasting a page.`); - }; - /** - * Unsubscribes the current page from the screencast session. - * @returns {Promise} - */ - private stopScreencast = async (): Promise => { - if (!this.client) { - logger.log('error', 'client is not initialized'); - logger.log('error', 'Screencast stop failed'); - } else { - await this.client.send('Page.stopScreencast'); - logger.log('info', `Browser stopped with screencasting.`); + try { + await this.client.send('Page.startScreencast', { + format: SCREENCAST_CONFIG.format, + }); + + // Set up screencast frame handler + this.client.on('Page.screencastFrame', async ({ data, sessionId }) => { + try { + const buffer = Buffer.from(data, 'base64'); + await this.emitScreenshot(buffer); + await this.client?.send('Page.screencastFrameAck', { sessionId }); + } catch (error) { + logger.error('Screencast frame processing failed:', error); + } + }); + + logger.info('Screencast started successfully'); + } catch (error) { + logger.error('Failed to start screencast:', error); } - }; + } + + private async stopScreencast(): Promise { + if (!this.client) { + logger.error('Client is not initialized'); + return; + } + + try { + await this.client.send('Page.stopScreencast'); + this.screenshotQueue = []; + this.isProcessingScreenshot = false; + logger.info('Screencast stopped successfully'); + } catch (error) { + logger.error('Failed to stop screencast:', error); + } + } + /** * Helper for emitting the screenshot of browser's active page through websocket. * @param payload the screenshot binary data * @returns void */ - private emitScreenshot = (payload: any): void => { - const dataWithMimeType = ('data:image/jpeg;base64,').concat(payload); - this.socket.emit('screencast', dataWithMimeType); - logger.log('debug', `Screenshot emitted`); + private emitScreenshot = async (payload: Buffer): Promise => { + if (this.isProcessingScreenshot) { + if (this.screenshotQueue.length < SCREENCAST_CONFIG.maxQueueSize) { + this.screenshotQueue.push(payload); + } + return; + } + + this.isProcessingScreenshot = true; + + try { + const optimizedScreenshot = await this.optimizeScreenshot(payload); + const base64Data = optimizedScreenshot.toString('base64'); + const dataWithMimeType = `data:image/jpeg;base64,${base64Data}`; + + this.socket.emit('screencast', dataWithMimeType); + logger.debug('Screenshot emitted'); + } catch (error) { + logger.error('Screenshot emission failed:', error); + } finally { + this.isProcessingScreenshot = false; + + if (this.screenshotQueue.length > 0) { + const nextScreenshot = this.screenshotQueue.shift(); + if (nextScreenshot) { + setTimeout(() => this.emitScreenshot(nextScreenshot), 1000 / SCREENCAST_CONFIG.targetFPS); + } + } + } }; + } diff --git a/server/src/types/index.ts b/server/src/types/index.ts index f2e327ef..75aac802 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -129,6 +129,17 @@ export interface BaseActionInfo { hasOnlyText: boolean; } + +interface IframeSelector { + full: string; + isIframe: boolean; +} + +interface ShadowSelector { + full: string; + mode: string; +} + /** * Holds all the possible css selectors that has been found for an element. * @category Types @@ -143,6 +154,8 @@ export interface Selectors { hrefSelector: string|null; accessibilitySelector: string|null; formSelector: string|null; + iframeSelector: IframeSelector|null; + shadowSelector: ShadowSelector|null; } /** @@ -156,7 +169,7 @@ export interface BaseAction extends BaseActionInfo{ associatedActions: ActionType[]; inputType: string | undefined; value: string | undefined; - selectors: { [key: string]: string | null }; + selectors: Selectors; timestamp: number; isPassword: boolean; /** diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index 609541de..d1bccbe4 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -730,15 +730,26 @@ export class WorkflowGenerator { const displaySelector = await this.generateSelector(page, coordinates, ActionType.Click); const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList); if (rect) { + const highlighterData = { + rect, + selector: displaySelector, + elementInfo, + // Include shadow DOM specific information + shadowInfo: elementInfo?.isShadowRoot ? { + mode: elementInfo.shadowRootMode, + content: elementInfo.shadowRootContent + } : null + }; + if (this.getList === true) { if (this.listSelector !== '') { const childSelectors = await getChildSelectors(page, this.listSelector || ''); - this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo, childSelectors }) + this.socket.emit('highlighter', { ...highlighterData, childSelectors }) } else { - this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); + this.socket.emit('highlighter', { ...highlighterData }); } } else { - this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); + this.socket.emit('highlighter', { ...highlighterData }); } } } diff --git a/server/src/workflow-management/selector.ts b/server/src/workflow-management/selector.ts index c9390f77..8a9096ec 100644 --- a/server/src/workflow-management/selector.ts +++ b/server/src/workflow-management/selector.ts @@ -23,10 +23,88 @@ export const getElementInformation = async ( if (!getList || listSelector !== '') { const elementInfo = await page.evaluate( async ({ x, y }) => { - const el = document.elementFromPoint(x, y) as HTMLElement; + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // First, get the element at the clicked coordinates in the main document + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Track the deepest element found + let deepestElement = element; + + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let shadowRoot = current.shadowRoot; + let deepest = current; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDocument = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDocument) break; + + const iframeElement = iframeDocument.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Update deepest element and check for shadow DOM + deepestElement = traverseShadowDOM(iframeElement); + + // Continue traversing if we found another iframe + if (iframeElement.tagName === 'IFRAME') { + currentIframe = iframeElement as HTMLIFrameElement; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + // Get the element and its iframe path + const el = getDeepestElementFromPoint(x, y); + if (el) { + // Handle potential anchor parent const { parentElement } = el; - const element = parentElement?.tagName === 'A' ? parentElement : el; + const targetElement = parentElement?.tagName === 'A' ? parentElement : el; + + // Get containing context information + const ownerDocument = targetElement.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + const isIframeContent = Boolean(frameElement); + + // Get the containing shadow root if any + const containingShadowRoot = targetElement.getRootNode() as ShadowRoot; + const isShadowRoot = containingShadowRoot instanceof ShadowRoot; + let info: { tagName: string; hasOnlyText?: boolean; @@ -36,51 +114,165 @@ export const getElementInformation = async ( attributes?: Record; innerHTML?: string; outerHTML?: string; + isIframeContent?: boolean; + iframeURL?: string; + iframeIndex?: number; + frameHierarchy?: string[]; + isShadowRoot?: boolean; + shadowRootMode?: string; + shadowRootContent?: string; } = { - tagName: element?.tagName ?? '', + tagName: targetElement?.tagName ?? '', + isIframeContent, + isShadowRoot }; - if (element) { - info.attributes = Array.from(element.attributes).reduce( + + if (isIframeContent) { + // Include iframe specific information + info.iframeURL = frameElement.src; + + // Calculate the frame's position in the hierarchy + let currentFrame = frameElement; + const frameHierarchy: string[] = []; + let frameIndex = 0; + + while (currentFrame) { + // Store the frame's identifier (src, id, or index) + frameHierarchy.unshift( + currentFrame.id || + currentFrame.src || + `iframe[${frameIndex}]` + ); + + // Move up to parent frame if it exists + const parentDoc = currentFrame.ownerDocument; + currentFrame = parentDoc?.defaultView?.frameElement as HTMLIFrameElement; + frameIndex++; + } + + info.frameHierarchy = frameHierarchy; + info.iframeIndex = frameIndex - 1; // Adjust for 0-based index + } + + if (isShadowRoot) { + // Include shadow root specific information + info.shadowRootMode = containingShadowRoot.mode; + info.shadowRootContent = containingShadowRoot.innerHTML; + } + + // Collect element attributes and properties + if (targetElement) { + info.attributes = Array.from(targetElement.attributes).reduce( (acc, attr) => { acc[attr.name] = attr.value; return acc; }, {} as Record ); + + if (targetElement.tagName === 'A') { + info.url = (targetElement as HTMLAnchorElement).href; + info.innerText = targetElement.textContent ?? ''; + } else if (targetElement.tagName === 'IMG') { + info.imageUrl = (targetElement as HTMLImageElement).src; + } else if (targetElement?.tagName === 'SELECT') { + const selectElement = targetElement as HTMLSelectElement; + info.innerText = selectElement.options[selectElement.selectedIndex]?.text ?? ''; + info.attributes = { + ...info.attributes, + selectedValue: selectElement.value, + }; + } else if (targetElement?.tagName === 'INPUT' && (targetElement as HTMLInputElement).type === 'time' || (targetElement as HTMLInputElement).type === 'date') { + info.innerText = (targetElement as HTMLInputElement).value; + } + else { + info.hasOnlyText = targetElement.children.length === 0 && + (targetElement.textContent !== null && + targetElement.textContent.trim().length > 0); + info.innerText = targetElement.textContent ?? ''; + } + + info.innerHTML = targetElement.innerHTML; + info.outerHTML = targetElement.outerHTML; } - // Gather specific information based on the tag - if (element?.tagName === 'A') { - info.url = (element as HTMLAnchorElement).href; - info.innerText = element.innerText ?? ''; - } else if (element?.tagName === 'IMG') { - info.imageUrl = (element as HTMLImageElement).src; - } else if (element?.tagName === 'SELECT') { - const selectElement = element as HTMLSelectElement; - info.innerText = selectElement.options[selectElement.selectedIndex]?.text ?? ''; - info.attributes = { - ...info.attributes, - selectedValue: selectElement.value, - }; - } else if (element?.tagName === 'INPUT' && (element as HTMLInputElement).type === 'time' || (element as HTMLInputElement).type === 'date') { - info.innerText = (element as HTMLInputElement).value; - } else { - info.hasOnlyText = element?.children?.length === 0 && - element?.innerText?.length > 0; - info.innerText = element?.innerText ?? ''; - } - info.innerHTML = element.innerHTML; - info.outerHTML = element.outerHTML; + return info; } return null; }, - { x: coordinates.x, y: coordinates.y }, + { x: coordinates.x, y: coordinates.y } ); return elementInfo; } else { const elementInfo = await page.evaluate( async ({ x, y }) => { - const originalEl = document.elementFromPoint(x, y) as HTMLElement; + // Enhanced helper function to get element from point including shadow DOM + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // First, get the element at the clicked coordinates in the main document + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Track the deepest element found + let deepestElement = element; + + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let shadowRoot = current.shadowRoot; + let deepest = current; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDocument = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDocument) break; + + const iframeElement = iframeDocument.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Update deepest element and check for shadow DOM + deepestElement = traverseShadowDOM(iframeElement); + + // Continue traversing if we found another iframe + if (iframeElement.tagName === 'IFRAME') { + currentIframe = iframeElement as HTMLIFrameElement; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + const originalEl = getDeepestElementFromPoint(x, y); if (originalEl) { let element = originalEl; @@ -124,6 +316,13 @@ export const getElementInformation = async ( } } } + + const ownerDocument = element.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + const isIframeContent = Boolean(frameElement); + + const containingShadowRoot = element.getRootNode() as ShadowRoot; + const isShadowRoot = containingShadowRoot instanceof ShadowRoot; let info: { tagName: string; @@ -134,11 +333,54 @@ export const getElementInformation = async ( attributes?: Record; innerHTML?: string; outerHTML?: string; + isIframeContent?: boolean; + iframeURL?: string; + iframeIndex?: number; + frameHierarchy?: string[]; + isShadowRoot?: boolean; + shadowRootMode?: string; + shadowRootContent?: string; } = { tagName: element?.tagName ?? '', + isIframeContent, + isShadowRoot }; + if (isIframeContent) { + // Include iframe specific information + info.iframeURL = frameElement.src; + + // Calculate the frame's position in the hierarchy + let currentFrame = frameElement; + const frameHierarchy: string[] = []; + let frameIndex = 0; + + while (currentFrame) { + // Store the frame's identifier (src, id, or index) + frameHierarchy.unshift( + currentFrame.id || + currentFrame.src || + `iframe[${frameIndex}]` + ); + + // Move up to parent frame if it exists + const parentDoc = currentFrame.ownerDocument; + currentFrame = parentDoc?.defaultView?.frameElement as HTMLIFrameElement; + frameIndex++; + } + + info.frameHierarchy = frameHierarchy; + info.iframeIndex = frameIndex - 1; // Adjust for 0-based index + }; + + if (isShadowRoot) { + // Include shadow root specific information + info.shadowRootMode = containingShadowRoot.mode; + info.shadowRootContent = containingShadowRoot.innerHTML; + } + if (element) { + // Get attributes including those from shadow DOM context info.attributes = Array.from(element.attributes).reduce( (acc, attr) => { acc[attr.name] = attr.value; @@ -146,21 +388,25 @@ export const getElementInformation = async ( }, {} as Record ); + + // Handle specific element types + if (element.tagName === 'A') { + info.url = (element as HTMLAnchorElement).href; + info.innerText = element.textContent ?? ''; + } else if (element.tagName === 'IMG') { + info.imageUrl = (element as HTMLImageElement).src; + } else { + // Handle text content with proper null checking + info.hasOnlyText = element.children.length === 0 && + (element.textContent !== null && + element.textContent.trim().length > 0); + info.innerText = element.textContent ?? ''; + } + + info.innerHTML = element.innerHTML; + info.outerHTML = element.outerHTML; } - - if (element?.tagName === 'A') { - info.url = (element as HTMLAnchorElement).href; - info.innerText = element.innerText ?? ''; - } else if (element?.tagName === 'IMG') { - info.imageUrl = (element as HTMLImageElement).src; - } else { - info.hasOnlyText = element?.children?.length === 0 && - element?.innerText?.length > 0; - info.innerText = element?.innerText ?? ''; - } - - info.innerHTML = element.innerHTML; - info.outerHTML = element.outerHTML; + return info; } return null; @@ -176,47 +422,206 @@ export const getElementInformation = async ( } }; -/** - * Returns a {@link Rectangle} object representing - * the coordinates, width, height and corner points of the element. - * If an element is not found, returns null. - * @param page The page instance. - * @param coordinates Coordinates of an element. - * @category WorkflowManagement-Selectors - * @returns {Promise} - */ export const getRect = async (page: Page, coordinates: Coordinates, listSelector: string, getList: boolean) => { try { if (!getList || listSelector !== '') { const rect = await page.evaluate( async ({ x, y }) => { - const el = document.elementFromPoint(x, y) as HTMLElement; + // Enhanced helper function to get element from point including iframes + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // First, get the element at the clicked coordinates in the main document + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Track the deepest element found + let deepestElement = element; + + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let shadowRoot = current.shadowRoot; + let deepest = current; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDocument = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDocument) break; + + const iframeElement = iframeDocument.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Update deepest element and check for shadow DOM + deepestElement = traverseShadowDOM(iframeElement); + + // Continue traversing if we found another iframe + if (iframeElement.tagName === 'IFRAME') { + currentIframe = iframeElement as HTMLIFrameElement; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + const el = getDeepestElementFromPoint(x, y); if (el) { const { parentElement } = el; - // Match the logic in recorder.ts for link clicks const element = parentElement?.tagName === 'A' ? parentElement : el; const rectangle = element?.getBoundingClientRect(); if (rectangle) { - return { - x: rectangle.x, - y: rectangle.y, - width: rectangle.width, - height: rectangle.height, - top: rectangle.top, - right: rectangle.right, - bottom: rectangle.bottom, - left: rectangle.left, - }; + const createRectObject = (rect: DOMRect) => ({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + toJSON() { + return { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + top: this.top, + right: this.right, + bottom: this.bottom, + left: this.left + }; + } + }); + + // For elements inside iframes, adjust coordinates relative to the top window + let adjustedRect = createRectObject(rectangle); + let currentWindow = element.ownerDocument.defaultView; + + while (currentWindow !== window.top) { + const frameElement = currentWindow?.frameElement as HTMLIFrameElement; + if (!frameElement) break; + + const frameRect = frameElement.getBoundingClientRect(); + adjustedRect = createRectObject({ + x: adjustedRect.x + frameRect.x, + y: adjustedRect.y + frameRect.y, + width: adjustedRect.width, + height: adjustedRect.height, + top: adjustedRect.top + frameRect.top, + right: adjustedRect.right + frameRect.left, + bottom: adjustedRect.bottom + frameRect.top, + left: adjustedRect.left + frameRect.left, + } as DOMRect); + + currentWindow = frameElement.ownerDocument.defaultView; + } + + return adjustedRect; } } + return null; }, - { x: coordinates.x, y: coordinates.y }, + { x: coordinates.x, y: coordinates.y } ); return rect; } else { const rect = await page.evaluate( async ({ x, y }) => { - const originalEl = document.elementFromPoint(x, y) as HTMLElement; + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // First, get the element at the clicked coordinates in the main document + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Track the deepest element found + let deepestElement = element; + + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let shadowRoot = current.shadowRoot; + let deepest = current; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDocument = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDocument) break; + + const iframeElement = iframeDocument.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Update deepest element and check for shadow DOM + deepestElement = traverseShadowDOM(iframeElement); + + // Continue traversing if we found another iframe + if (iframeElement.tagName === 'IFRAME') { + currentIframe = iframeElement as HTMLIFrameElement; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + const originalEl = getDeepestElementFromPoint(x, y); if (originalEl) { let element = originalEl; @@ -262,34 +667,69 @@ export const getRect = async (page: Page, coordinates: Coordinates, listSelector } const rectangle = element?.getBoundingClientRect(); - if (rectangle) { - return { - x: rectangle.x, - y: rectangle.y, - width: rectangle.width, - height: rectangle.height, - top: rectangle.top, - right: rectangle.right, - bottom: rectangle.bottom, - left: rectangle.left, - }; + const createRectObject = (rect: DOMRect) => ({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + toJSON() { + return { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + top: this.top, + right: this.right, + bottom: this.bottom, + left: this.left + }; + } + }); + + // Same coordinate adjustment for iframe elements as above + let adjustedRect = createRectObject(rectangle); + let currentWindow = element.ownerDocument.defaultView; + + while (currentWindow !== window.top) { + const frameElement = currentWindow?.frameElement as HTMLIFrameElement; + if (!frameElement) break; + + const frameRect = frameElement.getBoundingClientRect(); + adjustedRect = createRectObject({ + x: adjustedRect.x + frameRect.x, + y: adjustedRect.y + frameRect.y, + width: adjustedRect.width, + height: adjustedRect.height, + top: adjustedRect.top + frameRect.top, + right: adjustedRect.right + frameRect.left, + bottom: adjustedRect.bottom + frameRect.top, + left: adjustedRect.left + frameRect.left, + } as DOMRect); + + currentWindow = frameElement.ownerDocument.defaultView; + } + + return adjustedRect; } } return null; }, - { x: coordinates.x, y: coordinates.y }, + { x: coordinates.x, y: coordinates.y } ); return rect; } } catch (error) { const { message, stack } = error as Error; - logger.log('error', `Error while retrieving selector: ${message}`); - logger.log('error', `Stack: ${stack}`); + console.error('Error while retrieving selector:', message); + console.error('Stack:', stack); } }; - /** * Returns the best and unique css {@link Selectors} for the element on the page. * Internally uses a finder function from https://github.com/antonmedv/finder/blob/master/finder.ts @@ -759,6 +1199,206 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { } return output; } + + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // Helper function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement, depth: number = 0): HTMLElement => { + const MAX_SHADOW_DEPTH = 4; + let current = element; + let deepest = current; + + while (current && depth < MAX_SHADOW_DEPTH) { + const shadowRoot = current.shadowRoot; + if (!shadowRoot) break; + + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + depth++; + } + + return deepest; + }; + + // Start with the element at the specified coordinates + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Initialize tracking variables + let deepestElement = element; + let depth = 0; + const MAX_IFRAME_DEPTH = 4; + + // First check if the initial element has a shadow root + deepestElement = traverseShadowDOM(element); + + // If it's an iframe, traverse through iframe hierarchy + if (deepestElement.tagName === 'IFRAME') { + let currentIframe = deepestElement as HTMLIFrameElement; + + while (currentIframe && depth < MAX_IFRAME_DEPTH) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + // Access iframe's document + const iframeDoc = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDoc) break; + + // Get element at transformed coordinates in iframe + const iframeElement = iframeDoc.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Check for shadow DOM within iframe + const shadowResult = traverseShadowDOM(iframeElement); + deepestElement = shadowResult; + + // If we found another iframe, continue traversing + if (shadowResult.tagName === 'IFRAME') { + currentIframe = shadowResult as HTMLIFrameElement; + depth++; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } + + return deepestElement; + }; + + const genSelectorForIframe = (element: HTMLElement) => { + // Helper function to get the complete iframe path up to document root + const getIframePath = (el: HTMLElement) => { + const path = []; + let current = el; + let depth = 0; + const MAX_DEPTH = 4; + + while (current && depth < MAX_DEPTH) { + // Get the owner document of the current element + const ownerDocument = current.ownerDocument; + + // Check if this document belongs to an iframe + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + + if (frameElement) { + path.unshift({ + frame: frameElement, + document: ownerDocument, + element: current + }); + // Move up to the parent document's element (the iframe) + current = frameElement; + depth++; + } else { + break; + } + } + return path; + }; + + const iframePath = getIframePath(element); + if (iframePath.length === 0) return null; + + try { + const selectorParts: string[] = []; + + // Generate selector for each iframe boundary + iframePath.forEach((context, index) => { + // Get selector for the iframe element + const frameSelector = finder(context.frame, { + root: index === 0 ? document.body : + (iframePath[index - 1].document.body as Element) + }); + + // For the last context, get selector for target element + if (index === iframePath.length - 1) { + const elementSelector = finder(element, { + root: context.document.body as Element + }); + selectorParts.push(`${frameSelector} :>> ${elementSelector}`); + } else { + selectorParts.push(frameSelector); + } + }); + + return { + fullSelector: selectorParts.join(' :>> '), + isFrameContent: true + }; + } catch (e) { + console.warn('Error generating iframe selector:', e); + return null; + } + }; + + // Helper function to generate selectors for shadow DOM elements + const genSelectorForShadowDOM = (element: HTMLElement) => { + // Get complete path up to document root + const getShadowPath = (el: HTMLElement) => { + const path = []; + let current = el; + let depth = 0; + const MAX_DEPTH = 4; + + while (current && depth < MAX_DEPTH) { + const rootNode = current.getRootNode(); + if (rootNode instanceof ShadowRoot) { + path.unshift({ + host: rootNode.host as HTMLElement, + root: rootNode, + element: current + }); + current = rootNode.host as HTMLElement; + depth++; + } else { + break; + } + } + return path; + }; + + const shadowPath = getShadowPath(element); + if (shadowPath.length === 0) return null; + + try { + const selectorParts: string[] = []; + + // Generate selector for each shadow DOM boundary + shadowPath.forEach((context, index) => { + // Get selector for the host element + const hostSelector = finder(context.host, { + root: index === 0 ? document.body : (shadowPath[index - 1].root as unknown as Element) + }); + + // For the last context, get selector for target element + if (index === shadowPath.length - 1) { + const elementSelector = finder(element, { + root: context.root as unknown as Element + }); + selectorParts.push(`${hostSelector} >> ${elementSelector}`); + } else { + selectorParts.push(hostSelector); + } + }); + + return { + fullSelector: selectorParts.join(' >> '), + mode: shadowPath[shadowPath.length - 1].root.mode + }; + } catch (e) { + console.warn('Error generating shadow DOM selector:', e); + return null; + } + }; const genSelectors = (element: HTMLElement | null) => { if (element == null) { @@ -779,6 +1419,10 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { } catch (e) { } + + const iframeSelector = genSelectorForIframe(element); + const shadowSelector = genSelectorForShadowDOM(element); + const hrefSelector = genSelectorForAttributes(element, ['href']); const formSelector = genSelectorForAttributes(element, [ 'name', @@ -825,9 +1469,19 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { hrefSelector, accessibilitySelector, formSelector, + iframeSelector: iframeSelector ? { + full: iframeSelector.fullSelector, + isIframe: iframeSelector.isFrameContent, + } : null, + shadowSelector: shadowSelector ? { + full: shadowSelector.fullSelector, + mode: shadowSelector.mode + } : null }; } + + function genAttributeSet(element: HTMLElement, attributes: string[]) { return new Set( attributes.filter((attr) => { @@ -867,7 +1521,8 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { return char.length === 1 && char.match(/[0-9]/); } - const hoveredElement = document.elementFromPoint(x, y) as HTMLElement; + const hoveredElement = getDeepestElementFromPoint(x, y) as HTMLElement; + if ( hoveredElement != null && !hoveredElement.closest('#overlay-controls') != null @@ -902,9 +1557,83 @@ interface SelectorResult { */ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates, listSelector: string): Promise => { + interface DOMContext { + type: 'iframe' | 'shadow'; + element: HTMLElement; + container: HTMLIFrameElement | ShadowRoot; + host?: HTMLElement; + document?: Document; + } + try { if (!listSelector) { const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // Function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement): HTMLElement => { + let current = element; + let deepest = current; + let shadowRoot = current.shadowRoot; + + while (shadowRoot) { + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + shadowRoot = current.shadowRoot; + } + + return deepest; + }; + + // Start with the element at coordinates + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + let deepestElement = element; + let depth = 0; + const MAX_DEPTH = 4; + + // Handle iframe traversal + if (element.tagName === 'IFRAME') { + let currentIframe = element as HTMLIFrameElement; + + while (currentIframe && depth < MAX_DEPTH) { + try { + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + const iframeDoc = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDoc) break; + + const iframeElement = iframeDoc.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Check for shadow DOM within iframe + deepestElement = traverseShadowDOM(iframeElement); + + if (deepestElement.tagName === 'IFRAME') { + currentIframe = deepestElement as HTMLIFrameElement; + depth++; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } else { + // If not an iframe, check for shadow DOM + deepestElement = traverseShadowDOM(element); + } + + return deepestElement; + }; + + // Basic selector generation function getNonUniqueSelector(element: HTMLElement): string { let selector = element.tagName.toLowerCase(); @@ -928,22 +1657,95 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates return selector; } - function getSelectorPath(element: HTMLElement | null): string { - const path: string[] = []; - let depth = 0; - const maxDepth = 2; - while (element && element !== document.body && depth < maxDepth) { - const selector = getNonUniqueSelector(element); + function getContextPath(element: HTMLElement): DOMContext[] { + const path: DOMContext[] = []; + let current = element; + let depth = 0; + const MAX_DEPTH = 4; + + while (current && depth < MAX_DEPTH) { + // Check for shadow DOM + const rootNode = current.getRootNode(); + if (rootNode instanceof ShadowRoot) { + path.unshift({ + type: 'shadow', + element: current, + container: rootNode, + host: rootNode.host as HTMLElement + }); + current = rootNode.host as HTMLElement; + depth++; + continue; + } + + // Check for iframe + const ownerDocument = current.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + + if (frameElement) { + path.unshift({ + type: 'iframe', + element: current, + container: frameElement, + document: ownerDocument + }); + current = frameElement; + depth++; + continue; + } + + break; + } + + return path; + } + + function getSelectorPath(element: HTMLElement | null): string { + if (!element) return ''; + + // Get the complete context path + const contextPath = getContextPath(element); + if (contextPath.length > 0) { + const selectorParts: string[] = []; + + contextPath.forEach((context, index) => { + const containerSelector = getNonUniqueSelector( + context.type === 'shadow' ? context.host! : context.container as HTMLElement + ); + + if (index === contextPath.length - 1) { + const elementSelector = getNonUniqueSelector(element); + const delimiter = context.type === 'shadow' ? ' >> ' : ' :>> '; + selectorParts.push(`${containerSelector}${delimiter}${elementSelector}`); + } else { + selectorParts.push(containerSelector); + } + }); + + return selectorParts.join(contextPath[0].type === 'shadow' ? ' >> ' : ' :>> '); + } + + // Regular DOM path generation + const path: string[] = []; + let currentElement = element; + const MAX_DEPTH = 2; + let depth = 0; + + while (currentElement && currentElement !== document.body && depth < MAX_DEPTH) { + const selector = getNonUniqueSelector(currentElement); path.unshift(selector); - element = element.parentElement; + + if (!currentElement.parentElement) break; + currentElement = currentElement.parentElement; depth++; } return path.join(' > '); } - const originalEl = document.elementFromPoint(x, y) as HTMLElement; + // Main logic to get element and generate selector + const originalEl = getDeepestElementFromPoint(x, y); if (!originalEl) return null; let element = originalEl; @@ -989,16 +1791,90 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates } } } - // } const generalSelector = getSelectorPath(element); - return { - generalSelector, - }; + return { generalSelector }; }, coordinates); + return selectors || { generalSelector: '' }; } else { + // When we have a list selector, we need special handling while maintaining shadow DOM support const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { + const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { + // Helper function to traverse shadow DOM + const traverseShadowDOM = (element: HTMLElement, depth: number = 0): HTMLElement => { + const MAX_SHADOW_DEPTH = 4; + let current = element; + let deepest = current; + + while (current && depth < MAX_SHADOW_DEPTH) { + const shadowRoot = current.shadowRoot; + if (!shadowRoot) break; + + const shadowElement = shadowRoot.elementFromPoint(x, y) as HTMLElement; + if (!shadowElement || shadowElement === current) break; + + deepest = shadowElement; + current = shadowElement; + depth++; + } + + return deepest; + }; + + // Start with the element at the specified coordinates + let element = document.elementFromPoint(x, y) as HTMLElement; + if (!element) return null; + + // Initialize tracking variables + let deepestElement = element; + let depth = 0; + const MAX_IFRAME_DEPTH = 4; + + // First check if the initial element has a shadow root + deepestElement = traverseShadowDOM(element); + + // If it's an iframe, traverse through iframe hierarchy + if (deepestElement.tagName === 'IFRAME') { + let currentIframe = deepestElement as HTMLIFrameElement; + + while (currentIframe && depth < MAX_IFRAME_DEPTH) { + try { + // Convert coordinates to iframe's local space + const iframeRect = currentIframe.getBoundingClientRect(); + const iframeX = x - iframeRect.left; + const iframeY = y - iframeRect.top; + + // Access iframe's document + const iframeDoc = currentIframe.contentDocument || currentIframe.contentWindow?.document; + if (!iframeDoc) break; + + // Get element at transformed coordinates in iframe + const iframeElement = iframeDoc.elementFromPoint(iframeX, iframeY) as HTMLElement; + if (!iframeElement) break; + + // Check for shadow DOM within iframe + const shadowResult = traverseShadowDOM(iframeElement); + deepestElement = shadowResult; + + // If we found another iframe, continue traversing + if (shadowResult.tagName === 'IFRAME') { + currentIframe = shadowResult as HTMLIFrameElement; + depth++; + } else { + break; + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + break; + } + } + } + + return deepestElement; + }; + + // Generate basic selector from element's tag and classes function getNonUniqueSelector(element: HTMLElement): string { let selector = element.tagName.toLowerCase(); @@ -1009,9 +1885,9 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates } if (element.className) { - const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls)); + const classes = element.className.split(/\s+/).filter(Boolean); if (classes.length > 0) { - const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':')); + const validClasses = classes.filter(cls => !cls.startsWith('!') && !cls.includes(':')); if (validClasses.length > 0) { selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); } @@ -1021,34 +1897,104 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates return selector; } - function getSelectorPath(element: HTMLElement | null): string { - const path: string[] = []; + // Get complete context path (both iframe and shadow DOM) + function getContextPath(element: HTMLElement): DOMContext[] { + const path: DOMContext[] = []; + let current = element; let depth = 0; - const maxDepth = 2; + const MAX_DEPTH = 4; + + while (current && depth < MAX_DEPTH) { + // Check for shadow DOM + const rootNode = current.getRootNode(); + if (rootNode instanceof ShadowRoot) { + path.unshift({ + type: 'shadow', + element: current, + container: rootNode, + host: rootNode.host as HTMLElement + }); + current = rootNode.host as HTMLElement; + depth++; + continue; + } - while (element && element !== document.body && depth < maxDepth) { - const selector = getNonUniqueSelector(element); + // Check for iframe + const ownerDocument = current.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + + if (frameElement) { + path.unshift({ + type: 'iframe', + element: current, + container: frameElement, + document: ownerDocument + }); + current = frameElement; + depth++; + continue; + } + + break; + } + + return path; + } + + function getSelectorPath(element: HTMLElement | null): string { + if (!element) return ''; + + // Get the complete context path + const contextPath = getContextPath(element); + if (contextPath.length > 0) { + const selectorParts: string[] = []; + + contextPath.forEach((context, index) => { + const containerSelector = getNonUniqueSelector( + context.type === 'shadow' ? context.host! : context.container as HTMLElement + ); + + if (index === contextPath.length - 1) { + const elementSelector = getNonUniqueSelector(element); + const delimiter = context.type === 'shadow' ? ' >> ' : ' :>> '; + selectorParts.push(`${containerSelector}${delimiter}${elementSelector}`); + } else { + selectorParts.push(containerSelector); + } + }); + + return selectorParts.join(contextPath[0].type === 'shadow' ? ' >> ' : ' :>> '); + } + + // Regular DOM path generation + const path: string[] = []; + let currentElement = element; + const MAX_DEPTH = 2; + let depth = 0; + + while (currentElement && currentElement !== document.body && depth < MAX_DEPTH) { + const selector = getNonUniqueSelector(currentElement); path.unshift(selector); - element = element.parentElement; + + if (!currentElement.parentElement) break; + currentElement = currentElement.parentElement; depth++; } return path.join(' > '); } - const originalEl = document.elementFromPoint(x, y) as HTMLElement; - if (!originalEl) return null; + const originalEl = getDeepestElementFromPoint(x, y); + if (!originalEl) return { generalSelector: '' }; let element = originalEl; const generalSelector = getSelectorPath(element); - return { - generalSelector, - }; - }, coordinates); - return selectors || { generalSelector: '' }; - } + return { generalSelector }; + }, coordinates); + return selectors || { generalSelector: '' }; + } } catch (error) { console.error('Error in getNonUniqueSelectors:', error); return { generalSelector: '' }; @@ -1083,42 +2029,158 @@ export const getChildSelectors = async (page: Page, parentSelector: string): Pro } // Function to generate selector path from an element to its parent - function getSelectorPath(element: HTMLElement | null): string { + function getSelectorPath(element: HTMLElement): string { if (!element || !element.parentElement) return ''; - const parentSelector = getNonUniqueSelector(element.parentElement); const elementSelector = getNonUniqueSelector(element); + + // Check for shadow DOM context + const rootNode = element.getRootNode(); + if (rootNode instanceof ShadowRoot) { + const hostSelector = getNonUniqueSelector(rootNode.host as HTMLElement); + return `${hostSelector} >> ${elementSelector}`; + } + // Check for iframe context + const ownerDocument = element.ownerDocument; + const frameElement = ownerDocument?.defaultView?.frameElement as HTMLIFrameElement; + if (frameElement) { + const frameSelector = getNonUniqueSelector(frameElement); + return `${frameSelector} :>> ${elementSelector}`; + } + + // Regular DOM context + const parentSelector = getNonUniqueSelector(element.parentElement); return `${parentSelector} > ${elementSelector}`; } - // Function to recursively get all descendant selectors + + // Function to get all children from special contexts + function getSpecialContextChildren(element: HTMLElement): HTMLElement[] { + const children: HTMLElement[] = []; + + // Get shadow DOM children + const shadowRoot = element.shadowRoot; + if (shadowRoot) { + const shadowElements = Array.from(shadowRoot.querySelectorAll('*')) as HTMLElement[]; + children.push(...shadowElements); + } + + // Get iframe children + const iframes = Array.from(element.querySelectorAll('iframe')) as HTMLIFrameElement[]; + for (const iframe of iframes) { + try { + const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; + if (iframeDoc) { + const iframeElements = Array.from(iframeDoc.querySelectorAll('*')) as HTMLElement[]; + children.push(...iframeElements); + } + } catch (error) { + console.warn('Cannot access iframe content:', error); + continue; + } + } + + return children; + } + + // Function to recursively get all descendant selectors including shadow DOM and iframes function getAllDescendantSelectors(element: HTMLElement): string[] { let selectors: string[] = []; + + // Handle regular DOM children const children = Array.from(element.children) as HTMLElement[]; - for (const child of children) { const childPath = getSelectorPath(child); if (childPath) { - selectors.push(childPath); // Add direct child path - selectors = selectors.concat(getAllDescendantSelectors(child)); // Recursively process descendants + selectors.push(childPath); + + // Process regular descendants + selectors = selectors.concat(getAllDescendantSelectors(child)); + + // Process special context children (shadow DOM and iframes) + const specialChildren = getSpecialContextChildren(child); + for (const specialChild of specialChildren) { + const specialPath = getSelectorPath(specialChild); + if (specialPath) { + selectors.push(specialPath); + selectors = selectors.concat(getAllDescendantSelectors(specialChild)); + } + } + } + } + + // Handle direct special context children + const specialChildren = getSpecialContextChildren(element); + for (const specialChild of specialChildren) { + const specialPath = getSelectorPath(specialChild); + if (specialPath) { + selectors.push(specialPath); + selectors = selectors.concat(getAllDescendantSelectors(specialChild)); } } return selectors; } - // Find all occurrences of the parent selector in the DOM - const parentElements = Array.from(document.querySelectorAll(parentSelector)) as HTMLElement[]; + // Handle both shadow DOM and iframe parent selectors + let parentElements: HTMLElement[] = []; + + // Check for special context traversal in parent selector + if (parentSelector.includes('>>') || parentSelector.includes(':>>')) { + // Split by both types of delimiters + const selectorParts = parentSelector.split(/(?:>>|:>>)/).map(part => part.trim()); + + // Start with initial elements + parentElements = Array.from(document.querySelectorAll(selectorParts[0])) as HTMLElement[]; + + // Traverse through parts + for (let i = 1; i < selectorParts.length; i++) { + const newParentElements: HTMLElement[] = []; + + for (const element of parentElements) { + // Check for shadow DOM + if (element.shadowRoot) { + const shadowChildren = Array.from( + element.shadowRoot.querySelectorAll(selectorParts[i]) + ) as HTMLElement[]; + newParentElements.push(...shadowChildren); + } + + // Check for iframe + if (element.tagName === 'IFRAME') { + try { + const iframeDoc = (element as HTMLIFrameElement).contentDocument || + (element as HTMLIFrameElement).contentWindow?.document; + if (iframeDoc) { + const iframeChildren = Array.from( + iframeDoc.querySelectorAll(selectorParts[i]) + ) as HTMLElement[]; + newParentElements.push(...iframeChildren); + } + } catch (error) { + console.warn('Cannot access iframe content during traversal:', error); + continue; + } + } + } + + parentElements = newParentElements; + } + } else { + // Regular DOM selector + parentElements = Array.from(document.querySelectorAll(parentSelector)) as HTMLElement[]; + } + const allChildSelectors = new Set(); // Use a set to ensure uniqueness // Process each parent element and its descendants parentElements.forEach((parentElement) => { const descendantSelectors = getAllDescendantSelectors(parentElement); - descendantSelectors.forEach((selector) => allChildSelectors.add(selector)); // Add selectors to the set + descendantSelectors.forEach((selector) => allChildSelectors.add(selector)); }); - return Array.from(allChildSelectors); // Convert the set back to an array + return Array.from(allChildSelectors); }, parentSelector); return childSelectors || []; diff --git a/server/src/workflow-management/utils.ts b/server/src/workflow-management/utils.ts index b3dadd60..0804aa78 100644 --- a/server/src/workflow-management/utils.ts +++ b/server/src/workflow-management/utils.ts @@ -12,6 +12,16 @@ export const getBestSelectorForAction = (action: Action) => { case ActionType.Hover: case ActionType.DragAndDrop: { const selectors = action.selectors; + + + if (selectors?.iframeSelector?.full) { + return selectors.iframeSelector.full; + } + + if (selectors?.shadowSelector?.full) { + return selectors.shadowSelector.full; + } + // less than 25 characters, and element only has text inside const textSelector = selectors?.text?.length != null && @@ -75,6 +85,11 @@ export const getBestSelectorForAction = (action: Action) => { case ActionType.Input: case ActionType.Keydown: { const selectors = action.selectors; + + if (selectors?.shadowSelector?.full) { + return selectors.shadowSelector.full; + } + return ( selectors.testIdSelector ?? selectors?.id ?? diff --git a/src/App.tsx b/src/App.tsx index 02dff134..cdee8d40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { ThemeProvider, createTheme } from "@mui/material/styles"; import { GlobalInfoProvider } from "./context/globalInfo"; import { PageWrapper } from "./pages/PageWrappper"; import i18n from "./i18n"; +import ThemeModeProvider from './context/theme-provider'; const theme = createTheme({ @@ -85,15 +86,23 @@ const theme = createTheme({ function App() { return ( - + + + + } /> + + + + + // - - - } /> - - + // + // + // } /> + // + // - + // ); } diff --git a/src/api/storage.ts b/src/api/storage.ts index 18c793c0..201a38cc 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -1,7 +1,7 @@ import { default as axios } from "axios"; import { WorkflowFile } from "maxun-core"; -import { RunSettings } from "../components/molecules/RunSettings"; -import { ScheduleSettings } from "../components/molecules/ScheduleSettings"; +import { RunSettings } from "../components/run/RunSettings"; +import { ScheduleSettings } from "../components/robot/ScheduleSettings"; import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; import { apiUrl } from "../apiConfig"; diff --git a/src/components/molecules/ActionDescriptionBox.tsx b/src/components/action/ActionDescriptionBox.tsx similarity index 74% rename from src/components/molecules/ActionDescriptionBox.tsx rename to src/components/action/ActionDescriptionBox.tsx index 190c5838..97c979ec 100644 --- a/src/components/molecules/ActionDescriptionBox.tsx +++ b/src/components/action/ActionDescriptionBox.tsx @@ -5,19 +5,24 @@ import { useActionContext } from '../../context/browserActions'; import MaxunLogo from "../../assets/maxunlogo.png"; import { useTranslation } from 'react-i18next'; -const CustomBoxContainer = styled.div` +interface CustomBoxContainerProps { + isDarkMode: boolean; +} + +const CustomBoxContainer = styled.div` position: relative; min-width: 250px; width: auto; min-height: 100px; height: auto; - // border: 2px solid #ff00c3; border-radius: 5px; - background-color: white; + background-color: ${({ isDarkMode }) => (isDarkMode ? '#313438' : 'white')}; + color: ${({ isDarkMode }) => (isDarkMode ? 'white' : 'black')}; margin: 80px 13px 25px 13px; + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); `; -const Triangle = styled.div` +const Triangle = styled.div` position: absolute; top: -15px; left: 50%; @@ -26,7 +31,7 @@ const Triangle = styled.div` height: 0; border-left: 20px solid transparent; border-right: 20px solid transparent; - border-bottom: 20px solid white; + border-bottom: 20px solid ${({ isDarkMode }) => (isDarkMode ? '#313438' : 'white')}; `; const Logo = styled.img` @@ -44,7 +49,8 @@ const Content = styled.div` text-align: left; `; -const ActionDescriptionBox = () => { + +const ActionDescriptionBox = ({ isDarkMode }: { isDarkMode: boolean }) => { const { t } = useTranslation(); const { getText, getScreenshot, getList, captureStage } = useActionContext() as { getText: boolean; @@ -93,9 +99,19 @@ const ActionDescriptionBox = () => { } - label={{text}} + label={ + + {text} + + } /> ))} @@ -112,9 +128,9 @@ const ActionDescriptionBox = () => { }; return ( - - - + + + {renderActionDescription()} diff --git a/src/components/molecules/ActionSettings.tsx b/src/components/action/ActionSettings.tsx similarity index 70% rename from src/components/molecules/ActionSettings.tsx rename to src/components/action/ActionSettings.tsx index 79e120b8..a0a3aa59 100644 --- a/src/components/molecules/ActionSettings.tsx +++ b/src/components/action/ActionSettings.tsx @@ -1,16 +1,15 @@ import React, { useRef } from 'react'; import styled from "styled-components"; import { Button } from "@mui/material"; -//import { ActionDescription } from "../organisms/RightSidePanel"; import * as Settings from "./action-settings"; import { useSocketStore } from "../../context/socket"; interface ActionSettingsProps { action: string; + darkMode?: boolean; } -export const ActionSettings = ({ action }: ActionSettingsProps) => { - +export const ActionSettings = ({ action, darkMode = false }: ActionSettingsProps) => { const settingsRef = useRef<{ getSettings: () => object }>(null); const { socket } = useSocketStore(); @@ -20,30 +19,27 @@ export const ActionSettings = ({ action }: ActionSettingsProps) => { return ; case 'scroll': return ; - case 'scrape': - return ; + case 'scrape': + return ; case 'scrapeSchema': return ; default: return null; } - } + }; const handleSubmit = (event: React.SyntheticEvent) => { event.preventDefault(); - //get the data from settings const settings = settingsRef.current?.getSettings(); - //Send notification to the server and generate the pair socket?.emit(`action`, { action, settings }); - } + }; return (

- {/* Action settings: */} - +
diff --git a/src/components/molecules/BrowserNavBar.tsx b/src/components/browser/BrowserNavBar.tsx similarity index 69% rename from src/components/molecules/BrowserNavBar.tsx rename to src/components/browser/BrowserNavBar.tsx index ece1abcd..c3db51c1 100644 --- a/src/components/molecules/BrowserNavBar.tsx +++ b/src/components/browser/BrowserNavBar.tsx @@ -1,27 +1,34 @@ -import type { - FC, -} from 'react'; +import type { FC } from 'react'; import styled from 'styled-components'; - import ReplayIcon from '@mui/icons-material/Replay'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; - -import { NavBarButton } from '../atoms/buttons/buttons'; +import { NavBarButton } from '../ui/buttons/buttons'; import { UrlForm } from './UrlForm'; import { useCallback, useEffect, useState } from "react"; import { useSocketStore } from "../../context/socket"; import { getCurrentUrl } from "../../api/recording"; import { useGlobalInfoStore } from '../../context/globalInfo'; +import { useThemeMode } from '../../context/theme-provider'; -const StyledNavBar = styled.div<{ browserWidth: number }>` +const StyledNavBar = styled.div<{ browserWidth: number; isDarkMode: boolean }>` display: flex; padding: 12px 0px; - background-color: #f6f6f6; + background-color: ${({ isDarkMode }) => (isDarkMode ? '#2C2F33' : '#f6f6f6')}; width: ${({ browserWidth }) => browserWidth}px; border-radius: 0px 5px 0px 0px; `; +const IconButton = styled(NavBarButton) <{ mode: string }>` + background-color: ${({ mode }) => (mode === 'dark' ? '#2C2F33' : '#f6f6f6')}; + transition: background-color 0.3s ease, transform 0.1s ease; + color: ${({ mode }) => (mode === 'dark' ? '#FFFFFF' : '#333')}; + cursor: pointer; + &:hover { + background-color: ${({ mode }) => (mode === 'dark' ? '#586069' : '#D0D0D0')}; + } +`; + interface NavBarProps { browserWidth: number; handleUrlChanged: (url: string) => void; @@ -31,6 +38,7 @@ const BrowserNavBar: FC = ({ browserWidth, handleUrlChanged, }) => { + const isDarkMode = useThemeMode().darkMode; const { socket } = useSocketStore(); const { recordingUrl, setRecordingUrl } = useGlobalInfoStore(); @@ -67,7 +75,7 @@ const BrowserNavBar: FC = ({ socket.off('urlChanged', handleCurrentUrlChange); } } - }, [socket, handleCurrentUrlChange]) + }, [socket, handleCurrentUrlChange]); const addAddress = (address: string) => { if (socket) { @@ -78,38 +86,41 @@ const BrowserNavBar: FC = ({ }; return ( - - + { socket?.emit('input:back'); }} disabled={false} + mode={isDarkMode ? 'dark' : 'light'} > - + - { socket?.emit('input:forward'); }} disabled={false} + mode={isDarkMode ? 'dark' : 'light'} > - + - { if (socket) { - handleRefresh() + handleRefresh(); } }} disabled={false} + mode={isDarkMode ? 'dark' : 'light'} > - + { position: 'absolute', background: '#ff00c3', border: 'none', - borderRadius: '5px', + borderRadius: '0px 0px 8px 8px', padding: '7.5px', width: 'calc(100% - 20px)', overflow: 'hidden', display: 'flex', justifyContent: 'space-between', + height: "48px" }}> - @@ -179,10 +185,14 @@ const BrowserRecordingSave = () => { > {t('right_panel.buttons.confirm_reset')} - diff --git a/src/components/molecules/BrowserTabs.tsx b/src/components/browser/BrowserTabs.tsx similarity index 80% rename from src/components/molecules/BrowserTabs.tsx rename to src/components/browser/BrowserTabs.tsx index 0ee05dfc..0b3f7051 100644 --- a/src/components/molecules/BrowserTabs.tsx +++ b/src/components/browser/BrowserTabs.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { Box, IconButton, Tab, Tabs } from "@mui/material"; -import { AddButton } from "../atoms/buttons/AddButton"; import { useBrowserDimensionsStore } from "../../context/browserDimensions"; import { Close } from "@mui/icons-material"; +import { useThemeMode } from '../../context/theme-provider'; interface BrowserTabsProp { tabs: string[], @@ -28,15 +28,16 @@ export const BrowserTabs = ( handleChangeIndex(newValue); } }; + const isDarkMode = useThemeMode().darkMode; return ( - + {/* Synced border color */} { tabWasClosed = true; @@ -60,8 +65,7 @@ export const BrowserTabs = ( if (!tabWasClosed) { handleTabChange(index) } - } - } + }} label={tab} /> ); diff --git a/src/components/organisms/BrowserWindow.tsx b/src/components/browser/BrowserWindow.tsx similarity index 80% rename from src/components/organisms/BrowserWindow.tsx rename to src/components/browser/BrowserWindow.tsx index b69a0921..cde65f96 100644 --- a/src/components/organisms/BrowserWindow.tsx +++ b/src/components/browser/BrowserWindow.tsx @@ -1,18 +1,19 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useSocketStore } from '../../context/socket'; import { Button } from '@mui/material'; -import Canvas from "../atoms/canvas"; -import { Highlighter } from "../atoms/Highlighter"; -import { GenericModal } from '../atoms/GenericModal'; +import Canvas from "../recorder/canvas"; +import { Highlighter } from "../recorder/Highlighter"; +import { GenericModal } from '../ui/GenericModal'; import { useActionContext } from '../../context/browserActions'; import { useBrowserSteps, TextStep } from '../../context/browserSteps'; import { useGlobalInfoStore } from '../../context/globalInfo'; import { useTranslation } from 'react-i18next'; - interface ElementInfo { tagName: string; hasOnlyText?: boolean; + isIframeContent?: boolean; + isShadowRoot?: boolean; innerText?: string; url?: string; imageUrl?: string; @@ -117,31 +118,78 @@ export const BrowserWindow = () => { }, [screenShot, canvasRef, socket, screencastHandler]); const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => { + console.log("LIST SELECTOR", listSelector); + console.log("DATA SELECTOR", data.selector); + console.log("CHILD SELECTORS", data.childSelectors); if (getList === true) { if (listSelector) { socket?.emit('listSelector', { selector: listSelector }); + const hasValidChildSelectors = Array.isArray(data.childSelectors) && data.childSelectors.length > 0; + if (limitMode) { setHighlighterData(null); } else if (paginationMode) { - // only set highlighterData if type is not empty, 'none', 'scrollDown', or 'scrollUp' + // Only set highlighterData if type is not empty, 'none', 'scrollDown', or 'scrollUp' if (paginationType !== '' && !['none', 'scrollDown', 'scrollUp'].includes(paginationType)) { setHighlighterData(data); } else { setHighlighterData(null); } } else if (data.childSelectors && data.childSelectors.includes(data.selector)) { - // highlight only valid child elements within the listSelector + // Highlight only valid child elements within the listSelector setHighlighterData(data); + } else if (data.elementInfo?.isIframeContent && data.childSelectors) { + // Handle pure iframe elements - similar to previous shadow DOM logic but using iframe syntax + // Check if the selector matches any iframe child selectors + const isIframeChild = data.childSelectors.some(childSelector => + data.selector.includes(':>>') && // Iframe uses :>> for traversal + childSelector.split(':>>').some(part => + data.selector.includes(part.trim()) + ) + ); + setHighlighterData(isIframeChild ? data : null); + } else if (data.selector.includes(':>>') && hasValidChildSelectors) { + // Handle mixed DOM cases with iframes + // Split the selector into parts and check each against child selectors + const selectorParts = data.selector.split(':>>').map(part => part.trim()); + const isValidMixedSelector = selectorParts.some(part => + // We know data.childSelectors is defined due to hasValidChildSelectors check + data.childSelectors!.some(childSelector => + childSelector.includes(part) + ) + ); + setHighlighterData(isValidMixedSelector ? data : null); + } else if (data.elementInfo?.isShadowRoot && data.childSelectors) { + // New case: Handle pure Shadow DOM elements + // Check if the selector matches any shadow root child selectors + const isShadowChild = data.childSelectors.some(childSelector => + data.selector.includes('>>') && // Shadow DOM uses >> for piercing + childSelector.split('>>').some(part => + data.selector.includes(part.trim()) + ) + ); + setHighlighterData(isShadowChild ? data : null); + } else if (data.selector.includes('>>') && hasValidChildSelectors) { + // New case: Handle mixed DOM cases + // Split the selector into parts and check each against child selectors + const selectorParts = data.selector.split('>>').map(part => part.trim()); + const isValidMixedSelector = selectorParts.some(part => + // Now we know data.childSelectors is defined + data.childSelectors!.some(childSelector => + childSelector.includes(part) + ) + ); + setHighlighterData(isValidMixedSelector ? data : null); } else { // if !valid child in normal mode, clear the highlighter setHighlighterData(null); } } else { - // set highlighterData for the initial listSelector selection + // Set highlighterData for the initial listSelector selection setHighlighterData(data); } } else { - // for non-list steps + // For non-list steps setHighlighterData(data); } }, [highlighterData, getList, socket, listSelector, paginationMode, paginationType, captureStage]); @@ -192,6 +240,7 @@ export const BrowserWindow = () => { addTextStep('', data, { selector: highlighterData.selector, tag: highlighterData.elementInfo?.tagName, + shadow: highlighterData.elementInfo?.isShadowRoot, attribute }); } else { @@ -199,7 +248,7 @@ export const BrowserWindow = () => { setAttributeOptions(options); setSelectedElement({ selector: highlighterData.selector, - info: highlighterData.elementInfo + info: highlighterData.elementInfo, }); setShowAttributeModal(true); } @@ -236,6 +285,7 @@ export const BrowserWindow = () => { selectorObj: { selector: highlighterData.selector, tag: highlighterData.elementInfo?.tagName, + shadow: highlighterData.elementInfo?.isShadowRoot, attribute } }; @@ -283,6 +333,7 @@ export const BrowserWindow = () => { addTextStep('', data, { selector: selectedElement.selector, tag: selectedElement.info?.tagName, + shadow: selectedElement.info?.isShadowRoot, attribute: attribute }); } @@ -295,6 +346,7 @@ export const BrowserWindow = () => { selectorObj: { selector: selectedElement.selector, tag: selectedElement.info?.tagName, + shadow: selectedElement.info?.isShadowRoot, attribute: attribute } }; @@ -326,7 +378,6 @@ export const BrowserWindow = () => { } }, [paginationMode, resetPaginationSelector]); - return (
{ @@ -352,6 +403,11 @@ export const BrowserWindow = () => { overflow: 'hidden', padding: '5px 10px', }} + sx={{ + color: '#ff00c3 !important', + borderColor: '#ff00c3 !important', + backgroundColor: 'whitesmoke !important', + }} > void; } export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => { - const {t} = useTranslation(); + const theme = useTheme(); + const { t } = useTranslation(); const handleChange = (event: React.SyntheticEvent, newValue: string) => { handleChangeContent(newValue); }; + // Define colors based on theme mode + const defaultcolor = theme.palette.mode === 'light' ? 'black' : 'white'; + + const buttonStyles = { + justifyContent: 'flex-start', + textAlign: 'left', + fontSize: 'medium', + padding: '6px 16px 6px 22px', + minHeight: '48px', + minWidth: '100%', + display: 'flex', + alignItems: 'center', + textTransform: 'none', + color: theme.palette.mode === 'light' ? '#6C6C6C' : 'inherit', + }; + + return ( - + ); -} - -const buttonStyles = { - justifyContent: 'flex-start', - textAlign: 'left', - fontSize: 'medium', - padding: '6px 16px 6px 22px', - minHeight: '48px', - minWidth: '100%', - display: 'flex', - alignItems: 'center', - textTransform: 'none', - color: '#6C6C6C !important', }; \ No newline at end of file diff --git a/src/components/molecules/NavBar.tsx b/src/components/dashboard/NavBar.tsx similarity index 83% rename from src/components/molecules/NavBar.tsx rename to src/components/dashboard/NavBar.tsx index 8aeeb05d..5f7ce6b3 100644 --- a/src/components/molecules/NavBar.tsx +++ b/src/components/dashboard/NavBar.tsx @@ -4,17 +4,17 @@ import axios from 'axios'; import styled from "styled-components"; import { stopRecording } from "../../api/recording"; import { useGlobalInfoStore } from "../../context/globalInfo"; -import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar } from "@mui/material"; -import { AccountCircle, Logout, Clear, YouTube, X, Update, Close, Language } from "@mui/icons-material"; +import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar, Tooltip } from "@mui/material"; +import { AccountCircle, Logout, Clear, YouTube, X, Update, Close, Language, Brightness7, Brightness4, Description } from "@mui/icons-material"; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '../../context/auth'; -import { SaveRecording } from '../molecules/SaveRecording'; -import DiscordIcon from '../atoms/DiscordIcon'; +import { SaveRecording } from '../recorder/SaveRecording'; +import DiscordIcon from '../icons/DiscordIcon'; import { apiUrl } from '../../apiConfig'; import MaxunLogo from "../../assets/maxunlogo.png"; +import { useThemeMode } from '../../context/theme-provider'; import packageJson from "../../../package.json" - interface NavBarProps { recordingName: string; isRecording: boolean; @@ -28,6 +28,7 @@ export const NavBar: React.FC = ({ const { state, dispatch } = useContext(AuthContext); const { user } = state; const navigate = useNavigate(); + const { darkMode, toggleTheme } = useThemeMode(); const { t, i18n } = useTranslation(); const [anchorEl, setAnchorEl] = useState(null); @@ -102,6 +103,22 @@ export const NavBar: React.FC = ({ localStorage.setItem("language", lang); }; + const renderThemeToggle = () => ( + + + {darkMode ? : } + + + ); + useEffect(() => { const checkForUpdates = async () => { const latestVersion = await fetchLatestVersion(); @@ -158,13 +175,13 @@ export const NavBar: React.FC = ({ }} /> )} - +
-
{t('navbar.project_name')}
+
{t('navbar.project_name')}
= ({ docker-compose down

+ # replace existing docker-compose file with new one by copy pasting the code from +
+ Latest Docker Compose +
+
# pull latest docker images
docker-compose pull @@ -283,7 +305,6 @@ export const NavBar: React.FC = ({ borderRadius: '5px', padding: '8px', marginRight: '10px', - '&:hover': { backgroundColor: 'white', color: '#ff00c3' } }}> {user.email} @@ -305,6 +326,11 @@ export const NavBar: React.FC = ({ { handleMenuClose(); logout(); }}> {t('navbar.menu_items.logout')} + { + window.open('https://docs.maxun.dev', '_blank'); + }}> + Docs + { window.open('https://discord.gg/5GbPjBUkws', '_blank'); }}> @@ -376,8 +402,17 @@ export const NavBar: React.FC = ({ > Deutsch + { + window.open('https://docs.maxun.dev/development/i18n', '_blank'); + handleMenuClose(); + }} + > + Add Language + + {renderThemeToggle()} ) : ( <> @@ -397,18 +432,19 @@ export const NavBar: React.FC = ({ )}
) : ( - <> - {t("Language")} - + + + {t("Language")} + = ({ > Deutsch - + { + window.open('https://docs.maxun.dev/development/i18n', '_blank'); + handleMenuClose(); + }} + > + Add Language + + + {renderThemeToggle()} + )}
); }; -const NavBarWrapper = styled.div` +const NavBarWrapper = styled.div<{ mode: 'light' | 'dark' }>` grid-area: navbar; - background-color: white; + background-color: ${({ mode }) => (mode === 'dark' ? '#1e2124' : '#ffffff')}; padding: 5px; display: flex; justify-content: space-between; - border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid ${({ mode }) => (mode === 'dark' ? '#333' : '#e0e0e0')}; `; -const ProjectName = styled.b` - color: #3f4853; +const ProjectName = styled.b<{ mode: 'light' | 'dark' }>` + color: ${({ mode }) => (mode === 'dark' ? '#ffffff' : '#3f4853')}; font-size: 1.3em; `; + +const NavBarRight = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + margin-left: auto; +`; \ No newline at end of file diff --git a/src/components/atoms/DiscordIcon.tsx b/src/components/icons/DiscordIcon.tsx similarity index 100% rename from src/components/atoms/DiscordIcon.tsx rename to src/components/icons/DiscordIcon.tsx diff --git a/src/components/atoms/RecorderIcon.tsx b/src/components/icons/RecorderIcon.tsx similarity index 99% rename from src/components/atoms/RecorderIcon.tsx rename to src/components/icons/RecorderIcon.tsx index afed1756..ee07ec72 100644 --- a/src/components/atoms/RecorderIcon.tsx +++ b/src/components/icons/RecorderIcon.tsx @@ -14,7 +14,7 @@ export const RecordingIcon = () => { textIndent: 0, textTransform: 'none', }} - fill="white" + fill="black" d="m82.048,962.36c-0.18271-0.003-0.35147-0.001-0.53125,0.0312-0.69633,0.12662-1.3353,0.54943-1.7812,1.1562l-0.03125-0.0312-0.03125,0.0625-18.031,22.125h-44.438c-2.809,0-5.0938,2.2847-5.0938,5.0938v35.531c0,2.8091,2.2847,5.125,5.0938,5.125h20.562l-1.3125,4.5938h-0.71875c-1.1137,0-2.0312,0.9175-2.0312,2.0312v2.2188c0,1.1137,0.91751,2.0625,2.0312,2.0625h19.938c1.1137,0,2.0312-0.9488,2.0312-2.0625v-2.2188c0-1.1137-0.91751-2.0312-2.0312-2.0312h-0.71875l-1.3438-4.5938h20.438c2.809,0,5.0938-2.3159,5.0938-5.125v-35.531c0-1.7706-0.90663-3.3369-2.2812-4.25l10.531-17.625,0.03125-0.0625c0.84234-1.2783,0.51486-3.0308-0.75-3.9062l-3.0312-2.0938c-0.48208-0.33338-1.0456-0.49073-1.5938-0.5zm-0.21875,1.6875c0.28723-0.0523,0.57635,0.0338,0.84375,0.21875l3.0312,2.0938c0.53421,0.36973,0.65504,1.0569,0.28125,1.5938a0.85008,0.85008,0,0,0,-0.03125,0.0312l-17.906,30.062-9.0938-6.3125,22.094-27.125a0.85008,0.85008,0,0,0,0.03125,-0.0625c0.18694-0.26873,0.46277-0.4477,0.75-0.5zm-64.625,23.344,43.062,0-2.3438,2.9062-40.688,0c-0.0312-0.002-0.06255-0.002-0.09375,0-0.0312-0.002-0.06255-0.002-0.09375,0-0.38644,0.0753-0.69183,0.45007-0.6875,0.84375v34.844c0.003,0.4514,0.42377,0.857,0.875,0.8437h56.781c0.44088,0,0.84048-0.4028,0.84375-0.8437v-34.844c-0.008-0.25538-0.13841-0.50419-0.34375-0.65625l1.5-2.5c0.87419,0.61342,1.4375,1.6512,1.4375,2.8125v35.531c0,1.8967-1.5096,3.4063-3.4062,3.4063h-56.844c-1.8966,0-3.4062-1.5096-3.4062-3.4063v-35.531c0-1.8966,1.5096-3.4062,3.4062-3.4062zm0.875,4.5938,38.469,0-1.0312,1.25,0,0.0312c-0.48971,0.60518-0.64056,1.3922-0.5,2.0312,0.14234,0.64722,0.49536,1.1659,0.84375,1.6562a0.85008,0.85008,0,0,0,0.1875,0.21875l1.2812,0.875c-1.0387,0.79518-2.0706,1.1661-3.2188,1.6562-1.4337,0.61212-3.0045,1.4512-4.3438,3.375-1.1451,1.6448-1.0525,3.5446-0.78125,5.3437,0.27121,1.7991,0.70152,3.5802,0.5625,5.2188a0.85008,0.85008,0,0,0,1.2188,0.8437c1.4928-0.7039,3.3085-0.9361,5.0938-1.3125s3.6049-0.9489,4.75-2.5937c1.34-1.9249,1.5559-3.6628,1.625-5.2188,0.05552-1.2502,0.05447-2.363,0.4375-3.625l1.2812,0.875c1.2744,0.8814,3.0499,0.4785,3.8438-0.8437l0.03125-0.031,1.125-1.9063a0.85008,0.85008,0,0,0,0.03125,-0.0312l0.03125-0.0312a0.85008,0.85008,0,0,0,0.09375,-0.21875l4.0625-6.8125v32.406h-55.094v-33.156zm39.812,1.0625,9.3125,6.4375-0.84375,1.4062a0.85008,0.85008,0,0,0,-0.03125,0c-0.33037,0.5726-0.86691,0.7168-1.4062,0.3438l-2.1875-1.5-0.1875-0.15625-0.65625-0.4375-1.8438-1.2812-0.84375-0.59375-0.0625-0.0312-1.9688-1.3438c-0.25075-0.36937-0.4494-0.7387-0.5-0.96875-0.0558-0.25371-0.0497-0.34572,0.15625-0.59375l1.0625-1.2812zm0.84375,5.9688,0.34375,0.25,1.8438,1.25,0.375,0.25c-0.60662,1.6994-0.69236,3.2017-0.75,4.5-0.0657,1.481-0.18871,2.7295-1.3125,4.3438-0.76502,1.0988-2.0465,1.5537-3.7188,1.9062-1.3283,0.2801-2.854,0.5618-4.3438,1.0625-0.0521-1.5631-0.29881-3.0716-0.5-4.4062-0.25388-1.6841-0.29624-3.0262,0.46875-4.125,1.1246-1.6154,2.2602-2.1673,3.625-2.75,1.1932-0.5094,2.5901-1.1274,3.9688-2.2813zm-30.5,2.5313c-1.6815,0-3.0625,1.4119-3.0625,3.0937s1.381,3.0313,3.0625,3.0313,3.0625-1.3495,3.0625-3.0313-1.381-3.0937-3.0625-3.0937zm0,1.7187c0.76283,0,1.375,0.612,1.375,1.375s-0.61217,1.3438-1.375,1.3438-1.3438-0.5808-1.3438-1.3438,0.58092-1.375,1.3438-1.375zm8,5.6563c-3.3379,0.1812-7.1915,2.4749-10.344,4.6875-3.1522,2.2126-5.5625,4.4062-5.5625,4.4062-0.3273,0.3027-0.36527,0.8915-0.0625,1.2188,0.30273,0.3272,0.89151,0.334,1.2188,0.031,0,0,2.3185-2.1046,5.375-4.25s6.8989-4.2667,9.4688-4.4063c1.6177-0.088,4.3314,1.0381,6.5312,2.25,2.1999,1.212,3.9375,2.4375,3.9375,2.4375,0.35264,0.3353,1.001,0.2728,1.2812-0.125,0.28024-0.3977,0.12188-1.0307-0.3125-1.25,0,0-1.7602-1.2941-4.0625-2.5625-2.3024-1.2684-5.0831-2.567-7.4688-2.4375zm3.2812,22.562,12.344,0,1.3438,4.5312-15,0,1.3125-4.5312zm-3.7812,6.25,19.938,0c0.20135,0,0.3125,0.1424,0.3125,0.3437v2.2188c0,0.2013-0.11115,0.3437-0.3125,0.3437h-19.938c-0.20135,0-0.34375-0.1424-0.34375-0.3437v-2.2188c0-0.2013,0.1424-0.3437,0.34375-0.3437z" /> diff --git a/src/components/molecules/IntegrationSettings.tsx b/src/components/integration/IntegrationSettings.tsx similarity index 91% rename from src/components/molecules/IntegrationSettings.tsx rename to src/components/integration/IntegrationSettings.tsx index c4b13e8c..f9c397ae 100644 --- a/src/components/molecules/IntegrationSettings.tsx +++ b/src/components/integration/IntegrationSettings.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { GenericModal } from "../atoms/GenericModal"; +import { GenericModal } from "../ui/GenericModal"; import { MenuItem, Typography, @@ -17,6 +17,7 @@ import { apiUrl } from "../../apiConfig.js"; import Cookies from 'js-cookie'; import { useTranslation } from "react-i18next"; + interface IntegrationProps { isOpen: boolean; handleStart: (data: IntegrationSettings) => void; @@ -29,6 +30,20 @@ export interface IntegrationSettings { data: string; } +// Helper functions to replace js-cookie functionality +const getCookie = (name: string): string | null => { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { + return parts.pop()?.split(';').shift() || null; + } + return null; +}; + +const removeCookie = (name: string): void => { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; +}; + export const IntegrationSettingsModal = ({ isOpen, handleStart, @@ -141,14 +156,14 @@ export const IntegrationSettingsModal = ({ useEffect(() => { // Check if there is a success message in cookies - const status = Cookies.get("robot_auth_status"); - const message = Cookies.get("robot_auth_message"); + const status = getCookie("robot_auth_status"); + const message = getCookie("robot_auth_message"); if (status === "success" && message) { notify("success", message); // Clear the cookies after reading - Cookies.remove("robot_auth_status"); - Cookies.remove("robot_auth_message"); + removeCookie("robot_auth_status"); + removeCookie("robot_auth_message"); } // Check if we're on the callback URL @@ -172,11 +187,11 @@ export const IntegrationSettingsModal = ({ return (
+ display: "flex", + flexDirection: "column", + alignItems: "flex-start", + marginLeft: "65px", + }}> {t('integration_settings.title')} @@ -220,8 +235,8 @@ export const IntegrationSettingsModal = ({ <> {recording.google_sheet_email && ( - {t('integration_settings.descriptions.authenticated_as', { - email: recording.google_sheet_email + {t('integration_settings.descriptions.authenticated_as', { + email: recording.google_sheet_email })} )} @@ -309,4 +324,4 @@ export const modalStyle = { height: "fit-content", display: "block", padding: "20px", -}; +}; \ No newline at end of file diff --git a/src/components/molecules/AddWhatCondModal.tsx b/src/components/molecules/AddWhatCondModal.tsx deleted file mode 100644 index 5fadfaa6..00000000 --- a/src/components/molecules/AddWhatCondModal.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { WhereWhatPair } from "maxun-core"; -import { GenericModal } from "../atoms/GenericModal"; -import { modalStyle } from "./AddWhereCondModal"; -import { Button, MenuItem, TextField, Typography } from "@mui/material"; -import React, { useRef } from "react"; -import { Dropdown as MuiDropdown } from "../atoms/DropdownMui"; -import { KeyValueForm } from "./KeyValueForm"; -import { ClearButton } from "../atoms/buttons/ClearButton"; -import { useSocketStore } from "../../context/socket"; - -interface AddWhatCondModalProps { - isOpen: boolean; - onClose: () => void; - pair: WhereWhatPair; - index: number; -} - -export const AddWhatCondModal = ({isOpen, onClose, pair, index}: AddWhatCondModalProps) => { - const [action, setAction] = React.useState(''); - const [objectIndex, setObjectIndex] = React.useState(0); - const [args, setArgs] = React.useState<({type: string, value: (string|number|object|unknown)})[]>([]); - - const objectRefs = useRef<({getObject: () => object}|unknown)[]>([]); - - const {socket} = useSocketStore(); - - const handleSubmit = () => { - const argsArray: (string|number|object|unknown)[] = []; - args.map((arg, index) => { - switch (arg.type) { - case 'string': - case 'number': - argsArray[index] = arg.value; - break; - case 'object': - // @ts-ignore - argsArray[index] = objectRefs.current[arg.value].getObject(); - } - }) - setArgs([]); - onClose(); - pair.what.push({ - // @ts-ignore - action, - args: argsArray, - }) - socket?.emit('updatePair', {index: index-1, pair: pair}); - } - - return ( - { - setArgs([]); - onClose(); - }} modalStyle={modalStyle}> -
- Add what condition: -
- Action: - setAction(e.target.value)} - value={action} - label='action' - /> -
- Add new argument of type: - - - -
- args: - {args.map((arg, index) => { - // @ts-ignore - return ( -
- { - args.splice(index,1); - setArgs([...args]); - }}/> - {index}: - {arg.type === 'string' ? - setArgs([ - ...args.slice(0, index), - {type: arg.type, value: e.target.value}, - ...args.slice(index + 1) - ])} - value={args[index].value || ''} - label="string" - key={`arg-${arg.type}-${index}`} - /> : arg.type === 'number' ? - setArgs([ - ...args.slice(0, index), - {type: arg.type, value: Number(e.target.value)}, - ...args.slice(index + 1) - ])} - value={args[index].value || ''} - label="number" - /> : - - //@ts-ignore - objectRefs.current[arg.value] = el} key={`arg-${arg.type}-${index}`}/> - } -
- )})} - -
-
-
- ) -} diff --git a/src/components/molecules/AddWhereCondModal.tsx b/src/components/molecules/AddWhereCondModal.tsx deleted file mode 100644 index e5c34015..00000000 --- a/src/components/molecules/AddWhereCondModal.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Dropdown as MuiDropdown } from "../atoms/DropdownMui"; -import { - Button, - MenuItem, - Typography -} from "@mui/material"; -import React, { useRef } from "react"; -import { GenericModal } from "../atoms/GenericModal"; -import { WhereWhatPair } from "maxun-core"; -import { SelectChangeEvent } from "@mui/material/Select/Select"; -import { DisplayConditionSettings } from "./DisplayWhereConditionSettings"; -import { useSocketStore } from "../../context/socket"; - -interface AddWhereCondModalProps { - isOpen: boolean; - onClose: () => void; - pair: WhereWhatPair; - index: number; -} - -export const AddWhereCondModal = ({isOpen, onClose, pair, index}: AddWhereCondModalProps) => { - const [whereProp, setWhereProp] = React.useState(''); - const [additionalSettings, setAdditionalSettings] = React.useState(''); - const [newValue, setNewValue] = React.useState(''); - const [checked, setChecked] = React.useState(new Array(Object.keys(pair.where).length).fill(false)); - - const keyValueFormRef = useRef<{getObject: () => object}>(null); - - const {socket} = useSocketStore(); - - const handlePropSelect = (event: SelectChangeEvent) => { - setWhereProp(event.target.value); - switch (event.target.value) { - case 'url': setNewValue(''); break; - case 'selectors': setNewValue(['']); break; - case 'default': return; - } - } - - const handleSubmit = () => { - switch (whereProp) { - case 'url': - if (additionalSettings === 'string'){ - pair.where.url = newValue; - } else { - pair.where.url = { $regex: newValue }; - } - break; - case 'selectors': - pair.where.selectors = newValue; - break; - case 'cookies': - pair.where.cookies = keyValueFormRef.current?.getObject() as Record - break; - case 'before': - pair.where.$before = newValue; - break; - case 'after': - pair.where.$after = newValue; - break; - case 'boolean': - const booleanArr = []; - const deleteKeys: string[] = []; - for (let i = 0; i < checked.length; i++) { - if (checked[i]) { - if (Object.keys(pair.where)[i]) { - //@ts-ignore - if (pair.where[Object.keys(pair.where)[i]]) { - booleanArr.push({ - //@ts-ignore - [Object.keys(pair.where)[i]]: pair.where[Object.keys(pair.where)[i]]}); - } - deleteKeys.push(Object.keys(pair.where)[i]); - } - } - } - // @ts-ignore - deleteKeys.forEach((key: string) => delete pair.where[key]); - //@ts-ignore - pair.where[`$${additionalSettings}`] = booleanArr; - break; - default: - return; - } - onClose(); - setWhereProp(''); - setAdditionalSettings(''); - setNewValue(''); - socket?.emit('updatePair', {index: index-1, pair: pair}); - } - - return ( - { - setWhereProp(''); - setAdditionalSettings(''); - setNewValue(''); - onClose(); - }} modalStyle={modalStyle}> -
- Add where condition: -
- - url - selectors - cookies - before - after - boolean logic - -
- {whereProp ? -
- - -
- : null} -
-
- ) -} - -export const modalStyle = { - top: '40%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: '30%', - backgroundColor: 'background.paper', - p: 4, - height:'fit-content', - display:'block', - padding: '20px', -}; diff --git a/src/components/molecules/LeftSidePanelSettings.tsx b/src/components/molecules/LeftSidePanelSettings.tsx deleted file mode 100644 index a25ff2d1..00000000 --- a/src/components/molecules/LeftSidePanelSettings.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from "react"; -import { Button, MenuItem, TextField, Typography } from "@mui/material"; -import { Dropdown } from "../atoms/DropdownMui"; -import { RunSettings } from "./RunSettings"; -import { useSocketStore } from "../../context/socket"; - -interface LeftSidePanelSettingsProps { - params: any[] - settings: RunSettings, - setSettings: (setting: RunSettings) => void -} - -export const LeftSidePanelSettings = ({params, settings, setSettings}: LeftSidePanelSettingsProps) => { - const { socket } = useSocketStore(); - - return ( -
- { params.length !== 0 && ( - - Parameters: - { params?.map((item: string, index: number) => { - return setSettings( - { - ...settings, - params: settings.params - ? { - ...settings.params, - [item]: e.target.value, - } - : { - [item]: e.target.value, - }, - })} - /> - }) } - - )} - Interpreter: - setSettings( - { - ...settings, - maxConcurrency: parseInt(e.target.value), - })} - defaultValue={settings.maxConcurrency} - /> - setSettings( - { - ...settings, - maxRepeats: parseInt(e.target.value), - })} - defaultValue={settings.maxRepeats} - /> - setSettings( - { - ...settings, - debug: e.target.value === "true", - })} - > - true - false - - -
- ); -} diff --git a/src/components/molecules/PairDetail.tsx b/src/components/molecules/PairDetail.tsx deleted file mode 100644 index aea6191b..00000000 --- a/src/components/molecules/PairDetail.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import React, { useLayoutEffect, useRef, useState } from 'react'; -import { WhereWhatPair } from "maxun-core"; -import { Box, Button, IconButton, MenuItem, Stack, TextField, Tooltip, Typography } from "@mui/material"; -import { Close, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; -import TreeView from '@mui/lab/TreeView'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import TreeItem from '@mui/lab/TreeItem'; -import { AddButton } from "../atoms/buttons/AddButton"; -import { WarningText } from "../atoms/texts"; -import NotificationImportantIcon from '@mui/icons-material/NotificationImportant'; -import { RemoveButton } from "../atoms/buttons/RemoveButton"; -import { AddWhereCondModal } from "./AddWhereCondModal"; -import { UpdatePair } from "../../api/workflow"; -import { useSocketStore } from "../../context/socket"; -import { AddWhatCondModal } from "./AddWhatCondModal"; - -interface PairDetailProps { - pair: WhereWhatPair | null; - index: number; -} - -export const PairDetail = ({ pair, index }: PairDetailProps) => { - const [pairIsSelected, setPairIsSelected] = useState(false); - const [collapseWhere, setCollapseWhere] = useState(true); - const [collapseWhat, setCollapseWhat] = useState(true); - const [rerender, setRerender] = useState(false); - const [expanded, setExpanded] = React.useState( - pair ? Object.keys(pair.where).map((key, index) => `${key}-${index}`) : [] - ); - const [addWhereCondOpen, setAddWhereCondOpen] = useState(false); - const [addWhatCondOpen, setAddWhatCondOpen] = useState(false); - - const { socket } = useSocketStore(); - - const handleCollapseWhere = () => { - setCollapseWhere(!collapseWhere); - } - - const handleCollapseWhat = () => { - setCollapseWhat(!collapseWhat); - } - - const handleToggle = (event: React.SyntheticEvent, nodeIds: string[]) => { - setExpanded(nodeIds); - }; - - useLayoutEffect(() => { - if (pair) { - setPairIsSelected(true); - } - }, [pair]) - - const handleChangeValue = (value: any, where: boolean, keys: (string|number)[]) => { - // a moving reference to internal objects within pair.where or pair.what - let schema: any = where ? pair?.where : pair?.what; - const length = keys.length; - for(let i = 0; i < length-1; i++) { - const elem = keys[i]; - if( !schema[elem] ) schema[elem] = {} - schema = schema[elem]; - } - - schema[keys[length-1]] = value; - if (pair && socket) { - socket.emit('updatePair', {index: index-1, pair: pair}); - } - setRerender(!rerender); - } - - - const DisplayValueContent = (value: any, keys: (string|number)[], where: boolean = true) => { - switch (typeof(value)) { - case 'string': - return { - try { - const obj = JSON.parse(e.target.value); - handleChangeValue(obj, where, keys); - } catch (error) { - const num = Number(e.target.value); - if (!isNaN(num)) { - handleChangeValue(num, where, keys); - } - handleChangeValue(e.target.value, where, keys) - } - }} - defaultValue={value} - key={`text-field-${keys.join('-')}-${where}`} - /> - case 'number': - return handleChangeValue(Number(e.target.value), where, keys)} - defaultValue={value} - key={`text-field-${keys.join('-')}-${where}`} - /> - case 'object': - if (value) { - if (Array.isArray(value)) { - return ( - - { - value.map((element, index) => { - return DisplayValueContent(element, [...keys, index], where); - }) - } - { - let prevValue:any = where ? pair?.where : pair?.what; - for (const key of keys) { - prevValue = prevValue[key]; - } - handleChangeValue([...prevValue, ''], where, keys); - setRerender(!rerender); - }} hoverEffect={false}/> - { - let prevValue:any = where ? pair?.where : pair?.what; - for (const key of keys) { - prevValue = prevValue[key]; - } - prevValue.splice(-1); - handleChangeValue(prevValue, where, keys); - setRerender(!rerender); - }}/> - - ) - } else { - return ( - } - defaultExpandIcon={} - sx={{ flexGrow: 1, overflowY: 'auto' }} - key={`tree-view-nested-${keys.join('-')}-${where}`} - > - { - Object.keys(value).map((key2, index) => - { - return ( - - { DisplayValueContent(value[key2], [...keys, key2], where) } - - ) - }) - } - - ) - } - } - break; - default: - return null; - } - } - - return ( - - { pair && - - setAddWhatCondOpen(false)} - pair={pair} index={index}/> - setAddWhereCondOpen(false)} - pair={pair} index={index}/> - - } - { - pairIsSelected - ? ( -
- Pair number: {index} - { - if (pair && socket) { - socket.emit('updatePair', {index: index-1, pair: pair}); - pair.id = e.target.value; - } - }} - value={pair ? pair.id ? pair.id : '' : ''} - /> - - - Where - -
- { - setAddWhereCondOpen(true); - }} style={{color:'rgba(0, 0, 0, 0.54)', background:'transparent'}}/> -
-
-
- {(collapseWhere && pair && pair.where) - ? - - { Object.keys(pair.where).map((key, index) => { - return ( - } - defaultExpandIcon={} - sx={{ flexGrow: 1, overflowY: 'auto' }} - onNodeToggle={handleToggle} - key={`tree-view-${key}-${index}`} - > - - { - // @ts-ignore - DisplayValueContent(pair.where[key], [key]) - } - - - ); - })} - - : null - } - - - What - - -
- { - setAddWhatCondOpen(true); - }} style={{color:'rgba(0, 0, 0, 0.54)', background:'transparent'}}/> -
-
-
- {(collapseWhat && pair && pair.what) - ?( - - { Object.keys(pair.what).map((key, index) => { - return ( - } - defaultExpandIcon={} - sx={{ flexGrow: 1, overflowY: 'auto' }} - key={`tree-view-2-${key}-${index}`} - > - - { - // @ts-ignore - DisplayValueContent(pair.what[key], [key], false) - } - -
- { - //@ts-ignore - pair.what.splice(key, 1); - setRerender(!rerender); - }}/> -
-
-
-
- ); - })} -
- ) - : null - } -
- ) - : - - No pair from the left side panel was selected. - - } -
- ); -} - -interface CollapseButtonProps { - handleClick: () => void; - isCollapsed?: boolean; -} - -const CollapseButton = ({handleClick, isCollapsed } : CollapseButtonProps) => { - return ( - - { isCollapsed ? : } - - ); -} - -const CloseButton = ({handleClick } : CollapseButtonProps) => { - return ( - - - - ); -} diff --git a/src/components/atoms/DatePicker.tsx b/src/components/pickers/DatePicker.tsx similarity index 86% rename from src/components/atoms/DatePicker.tsx rename to src/components/pickers/DatePicker.tsx index 30d3b869..61363984 100644 --- a/src/components/atoms/DatePicker.tsx +++ b/src/components/pickers/DatePicker.tsx @@ -27,7 +27,7 @@ const DatePicker: React.FC = ({ coordinates, selector, onClose }; return ( -
= ({ coordinates, selector, onClose autoFocus />
- - diff --git a/src/components/atoms/DateTimeLocalPicker.tsx b/src/components/pickers/DateTimeLocalPicker.tsx similarity index 87% rename from src/components/atoms/DateTimeLocalPicker.tsx rename to src/components/pickers/DateTimeLocalPicker.tsx index dc62a79b..c51e3540 100644 --- a/src/components/atoms/DateTimeLocalPicker.tsx +++ b/src/components/pickers/DateTimeLocalPicker.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useSocketStore } from '../../context/socket'; -import { Coordinates } from './canvas'; +import { Coordinates } from '../recorder/canvas'; interface DateTimeLocalPickerProps { coordinates: Coordinates; @@ -27,7 +27,7 @@ const DateTimeLocalPicker: React.FC = ({ coordinates, }; return ( -
= ({ coordinates, autoFocus />
- - diff --git a/src/components/atoms/Dropdown.tsx b/src/components/pickers/Dropdown.tsx similarity index 86% rename from src/components/atoms/Dropdown.tsx rename to src/components/pickers/Dropdown.tsx index c7ead64b..df695efa 100644 --- a/src/components/atoms/Dropdown.tsx +++ b/src/components/pickers/Dropdown.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useSocketStore } from '../../context/socket'; -import { Coordinates } from './canvas'; +import { Coordinates } from '../recorder/canvas'; interface DropdownProps { coordinates: Coordinates; @@ -47,20 +47,20 @@ const Dropdown = ({ coordinates, selector, options, onClose }: DropdownProps) => lineHeight: '18px', padding: '0 3px', cursor: option.disabled ? 'default' : 'default', - backgroundColor: hoveredIndex === index ? '#0078D7' : - option.selected ? '#0078D7' : - option.disabled ? '#f8f8f8' : 'white', - color: (hoveredIndex === index || option.selected) ? 'white' : - option.disabled ? '#a0a0a0' : 'black', + backgroundColor: hoveredIndex === index ? '#0078D7' : + option.selected ? '#0078D7' : + option.disabled ? '#f8f8f8' : 'white', + color: (hoveredIndex === index || option.selected) ? 'white' : + option.disabled ? '#a0a0a0' : 'black', userSelect: 'none', }); return ( -
-
e.stopPropagation()} > diff --git a/src/components/atoms/TimePicker.tsx b/src/components/pickers/TimePicker.tsx similarity index 95% rename from src/components/atoms/TimePicker.tsx rename to src/components/pickers/TimePicker.tsx index 31353c7a..7877787e 100644 --- a/src/components/atoms/TimePicker.tsx +++ b/src/components/pickers/TimePicker.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useSocketStore } from '../../context/socket'; -import { Coordinates } from './canvas'; +import { Coordinates } from '../recorder/canvas'; interface TimePickerProps { coordinates: Coordinates; @@ -69,7 +69,7 @@ const TimePicker = ({ coordinates, selector, onClose }: TimePickerProps) => { const getOptionStyle = (value: number, isHour: boolean): React.CSSProperties => { const isHovered = isHour ? hoveredHour === value : hoveredMinute === value; const isSelected = isHour ? selectedHour === value : selectedMinute === value; - + return { fontSize: '13.333px', lineHeight: '18px', @@ -85,11 +85,11 @@ const TimePicker = ({ coordinates, selector, onClose }: TimePickerProps) => { const minutes = Array.from({ length: 60 }, (_, i) => i); return ( -
-
e.stopPropagation()} > @@ -109,7 +109,7 @@ const TimePicker = ({ coordinates, selector, onClose }: TimePickerProps) => {
{/* Minutes column */} -
+
{minutes.map((minute) => (
{ fetchProxyConfig(); }, []); + const theme = useThemeMode(); + const isDarkMode = theme.darkMode; + return ( <> @@ -144,6 +166,7 @@ const ProxyForm: React.FC = () => { + {tabIndex === 0 && ( isProxyConfigured ? ( @@ -236,14 +259,20 @@ const ProxyForm: React.FC = () => { {t('proxy.coming_soon')} + + {/* )} - + + {t('proxy.alert.title')}
{t('proxy.alert.right_way')} @@ -257,6 +286,7 @@ const ProxyForm: React.FC = () => {
{t('proxy.alert.wrong_way')}
+ {t('proxy.alert.proxy_url')} http://myusername:mypassword@proxy.com:1337
diff --git a/src/components/recorder/AddWhatCondModal.tsx b/src/components/recorder/AddWhatCondModal.tsx new file mode 100644 index 00000000..a6b50fc9 --- /dev/null +++ b/src/components/recorder/AddWhatCondModal.tsx @@ -0,0 +1,134 @@ +import { WhereWhatPair } from "maxun-core"; +import { GenericModal } from "../ui/GenericModal"; +import { modalStyle } from "./AddWhereCondModal"; +import { Button, MenuItem, TextField, Typography } from "@mui/material"; +import React, { useRef } from "react"; +import { Dropdown as MuiDropdown } from "../ui/DropdownMui"; +import { KeyValueForm } from "./KeyValueForm"; +import { ClearButton } from "../ui/buttons/ClearButton"; +import { useSocketStore } from "../../context/socket"; + +interface AddWhatCondModalProps { + isOpen: boolean; + onClose: () => void; + pair: WhereWhatPair; + index: number; +} + +export const AddWhatCondModal = ({ isOpen, onClose, pair, index }: AddWhatCondModalProps) => { + const [action, setAction] = React.useState(''); + const [objectIndex, setObjectIndex] = React.useState(0); + const [args, setArgs] = React.useState<({ type: string, value: (string | number | object | unknown) })[]>([]); + + const objectRefs = useRef<({ getObject: () => object } | unknown)[]>([]); + + const { socket } = useSocketStore(); + + const handleSubmit = () => { + const argsArray: (string | number | object | unknown)[] = []; + args.map((arg, index) => { + switch (arg.type) { + case 'string': + case 'number': + argsArray[index] = arg.value; + break; + case 'object': + // @ts-ignore + argsArray[index] = objectRefs.current[arg.value].getObject(); + } + }) + setArgs([]); + onClose(); + pair.what.push({ + // @ts-ignore + action, + args: argsArray, + }) + socket?.emit('updatePair', { index: index - 1, pair: pair }); + } + + return ( + { + setArgs([]); + onClose(); + }} modalStyle={modalStyle}> +
+ Add what condition: +
+ Action: + setAction(e.target.value)} + value={action} + label='action' + /> +
+ Add new argument of type: + + + +
+ args: + {args.map((arg, index) => { + // @ts-ignore + return ( +
+ { + args.splice(index, 1); + setArgs([...args]); + }} /> + {index}: + {arg.type === 'string' ? + setArgs([ + ...args.slice(0, index), + { type: arg.type, value: e.target.value }, + ...args.slice(index + 1) + ])} + value={args[index].value || ''} + label="string" + key={`arg-${arg.type}-${index}`} + /> : arg.type === 'number' ? + setArgs([ + ...args.slice(0, index), + { type: arg.type, value: Number(e.target.value) }, + ...args.slice(index + 1) + ])} + value={args[index].value || ''} + label="number" + /> : + + //@ts-ignore + objectRefs.current[arg.value] = el} key={`arg-${arg.type}-${index}`} /> + } +
+ ) + })} + +
+
+
+ ) +} diff --git a/src/components/recorder/AddWhereCondModal.tsx b/src/components/recorder/AddWhereCondModal.tsx new file mode 100644 index 00000000..7c5c284c --- /dev/null +++ b/src/components/recorder/AddWhereCondModal.tsx @@ -0,0 +1,152 @@ +import { Dropdown as MuiDropdown } from "../ui/DropdownMui"; +import { + Button, + MenuItem, + Typography +} from "@mui/material"; +import React, { useRef } from "react"; +import { GenericModal } from "../ui/GenericModal"; +import { WhereWhatPair } from "maxun-core"; +import { SelectChangeEvent } from "@mui/material/Select/Select"; +import { DisplayConditionSettings } from "./DisplayWhereConditionSettings"; +import { useSocketStore } from "../../context/socket"; + +interface AddWhereCondModalProps { + isOpen: boolean; + onClose: () => void; + pair: WhereWhatPair; + index: number; +} + +export const AddWhereCondModal = ({ isOpen, onClose, pair, index }: AddWhereCondModalProps) => { + const [whereProp, setWhereProp] = React.useState(''); + const [additionalSettings, setAdditionalSettings] = React.useState(''); + const [newValue, setNewValue] = React.useState(''); + const [checked, setChecked] = React.useState(new Array(Object.keys(pair.where).length).fill(false)); + + const keyValueFormRef = useRef<{ getObject: () => object }>(null); + + const { socket } = useSocketStore(); + + const handlePropSelect = (event: SelectChangeEvent) => { + setWhereProp(event.target.value); + switch (event.target.value) { + case 'url': setNewValue(''); break; + case 'selectors': setNewValue(['']); break; + case 'default': return; + } + } + + const handleSubmit = () => { + switch (whereProp) { + case 'url': + if (additionalSettings === 'string') { + pair.where.url = newValue; + } else { + pair.where.url = { $regex: newValue }; + } + break; + case 'selectors': + pair.where.selectors = newValue; + break; + case 'cookies': + pair.where.cookies = keyValueFormRef.current?.getObject() as Record + break; + case 'before': + pair.where.$before = newValue; + break; + case 'after': + pair.where.$after = newValue; + break; + case 'boolean': + const booleanArr = []; + const deleteKeys: string[] = []; + for (let i = 0; i < checked.length; i++) { + if (checked[i]) { + if (Object.keys(pair.where)[i]) { + //@ts-ignore + if (pair.where[Object.keys(pair.where)[i]]) { + booleanArr.push({ + //@ts-ignore + [Object.keys(pair.where)[i]]: pair.where[Object.keys(pair.where)[i]] + }); + } + deleteKeys.push(Object.keys(pair.where)[i]); + } + } + } + // @ts-ignore + deleteKeys.forEach((key: string) => delete pair.where[key]); + //@ts-ignore + pair.where[`$${additionalSettings}`] = booleanArr; + break; + default: + return; + } + onClose(); + setWhereProp(''); + setAdditionalSettings(''); + setNewValue(''); + socket?.emit('updatePair', { index: index - 1, pair: pair }); + } + + return ( + { + setWhereProp(''); + setAdditionalSettings(''); + setNewValue(''); + onClose(); + }} modalStyle={modalStyle}> +
+ Add where condition: +
+ + url + selectors + cookies + before + after + boolean logic + +
+ {whereProp ? +
+ + +
+ : null} +
+
+ ) +} + +export const modalStyle = { + top: '40%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '30%', + backgroundColor: 'background.paper', + p: 4, + height: 'fit-content', + display: 'block', + padding: '20px', +}; diff --git a/src/components/molecules/DisplayWhereConditionSettings.tsx b/src/components/recorder/DisplayWhereConditionSettings.tsx similarity index 71% rename from src/components/molecules/DisplayWhereConditionSettings.tsx rename to src/components/recorder/DisplayWhereConditionSettings.tsx index 98d63459..784d26c4 100644 --- a/src/components/molecules/DisplayWhereConditionSettings.tsx +++ b/src/components/recorder/DisplayWhereConditionSettings.tsx @@ -1,10 +1,10 @@ import React from "react"; -import { Dropdown as MuiDropdown } from "../atoms/DropdownMui"; +import { Dropdown as MuiDropdown } from "../ui/DropdownMui"; import { Checkbox, FormControlLabel, FormGroup, MenuItem, Stack, TextField } from "@mui/material"; -import { AddButton } from "../atoms/buttons/AddButton"; -import { RemoveButton } from "../atoms/buttons/RemoveButton"; +import { AddButton } from "../ui/buttons/AddButton"; +import { RemoveButton } from "../ui/buttons/RemoveButton"; import { KeyValueForm } from "./KeyValueForm"; -import { WarningText } from "../atoms/texts"; +import { WarningText } from "../ui/texts"; interface DisplayConditionSettingsProps { whereProp: string; @@ -12,15 +12,15 @@ interface DisplayConditionSettingsProps { setAdditionalSettings: (value: any) => void; newValue: any; setNewValue: (value: any) => void; - keyValueFormRef: React.RefObject<{getObject: () => object}>; + keyValueFormRef: React.RefObject<{ getObject: () => object }>; whereKeys: string[]; checked: boolean[]; setChecked: (value: boolean[]) => void; } export const DisplayConditionSettings = ( - {whereProp, setAdditionalSettings, additionalSettings, - setNewValue, newValue, keyValueFormRef, whereKeys, checked, setChecked} + { whereProp, setAdditionalSettings, additionalSettings, + setNewValue, newValue, keyValueFormRef, whereKeys, checked, setChecked } : DisplayConditionSettingsProps) => { switch (whereProp) { case 'url': @@ -34,7 +34,7 @@ export const DisplayConditionSettings = ( string regex - { additionalSettings ? setNewValue(e.target.value)} @@ -56,20 +56,20 @@ export const DisplayConditionSettings = ( ...newValue.slice(0, index), e.target.value, ...newValue.slice(index + 1) - ])}/> + ])} /> }) } - setNewValue([...newValue, ''])}/> - { + setNewValue([...newValue, ''])} /> + { const arr = newValue; arr.splice(-1); setNewValue([...arr]); - }}/> + }} /> ) case 'cookies': - return + return case 'before': return or - { - whereKeys.map((key: string, index: number) => { - return ( - setChecked([ - ...checked.slice(0, index), - !checked[index], - ...checked.slice(index + 1) - ])} - key={`checkbox-${key}-${index}`} - /> - } label={key} key={`control-label-form-${key}-${index}`}/> - ) - }) - } + { + whereKeys.map((key: string, index: number) => { + return ( + setChecked([ + ...checked.slice(0, index), + !checked[index], + ...checked.slice(index + 1) + ])} + key={`checkbox-${key}-${index}`} + /> + } label={key} key={`control-label-form-${key}-${index}`} /> + ) + }) + } Choose at least 2 where conditions. Nesting of boolean operators diff --git a/src/components/atoms/Highlighter.tsx b/src/components/recorder/Highlighter.tsx similarity index 100% rename from src/components/atoms/Highlighter.tsx rename to src/components/recorder/Highlighter.tsx diff --git a/src/components/molecules/KeyValueForm.tsx b/src/components/recorder/KeyValueForm.tsx similarity index 71% rename from src/components/molecules/KeyValueForm.tsx rename to src/components/recorder/KeyValueForm.tsx index c6dd374a..3db70135 100644 --- a/src/components/molecules/KeyValueForm.tsx +++ b/src/components/recorder/KeyValueForm.tsx @@ -1,11 +1,11 @@ import React, { forwardRef, useImperativeHandle, useRef } from 'react'; -import { KeyValuePair } from "../atoms/KeyValuePair"; -import { AddButton } from "../atoms/buttons/AddButton"; -import { RemoveButton } from "../atoms/buttons/RemoveButton"; +import { KeyValuePair } from "./KeyValuePair"; +import { AddButton } from "../ui/buttons/AddButton"; +import { RemoveButton } from "../ui/buttons/RemoveButton"; export const KeyValueForm = forwardRef((props, ref) => { const [numberOfPairs, setNumberOfPairs] = React.useState(1); - const keyValuePairRefs = useRef<{getKeyValuePair: () => { key: string, value: string }}[]>([]); + const keyValuePairRefs = useRef<{ getKeyValuePair: () => { key: string, value: string } }[]>([]); useImperativeHandle(ref, () => ({ getObject() { @@ -28,12 +28,12 @@ export const KeyValueForm = forwardRef((props, ref) => { { new Array(numberOfPairs).fill(1).map((_, index) => { return keyValuePairRefs.current[index] = el}/> + //@ts-ignore + ref={el => keyValuePairRefs.current[index] = el} /> }) } - setNumberOfPairs(numberOfPairs + 1)} hoverEffect={false}/> - setNumberOfPairs(numberOfPairs - 1)}/> + setNumberOfPairs(numberOfPairs + 1)} hoverEffect={false} /> + setNumberOfPairs(numberOfPairs - 1)} />
); }); diff --git a/src/components/atoms/KeyValuePair.tsx b/src/components/recorder/KeyValuePair.tsx similarity index 100% rename from src/components/atoms/KeyValuePair.tsx rename to src/components/recorder/KeyValuePair.tsx diff --git a/src/components/organisms/LeftSidePanel.tsx b/src/components/recorder/LeftSidePanel.tsx similarity index 93% rename from src/components/organisms/LeftSidePanel.tsx rename to src/components/recorder/LeftSidePanel.tsx index 29196d20..afe56ffe 100644 --- a/src/components/organisms/LeftSidePanel.tsx +++ b/src/components/recorder/LeftSidePanel.tsx @@ -3,14 +3,14 @@ import React, { useCallback, useEffect, useState } from "react"; import { getActiveWorkflow, getParamsOfActiveWorkflow } from "../../api/workflow"; import { useSocketStore } from '../../context/socket'; import { WhereWhatPair, WorkflowFile } from "maxun-core"; -import { SidePanelHeader } from "../molecules/SidePanelHeader"; +import { SidePanelHeader } from "./SidePanelHeader"; import { emptyWorkflow } from "../../shared/constants"; -import { LeftSidePanelContent } from "../molecules/LeftSidePanelContent"; +import { LeftSidePanelContent } from "./LeftSidePanelContent"; import { useBrowserDimensionsStore } from "../../context/browserDimensions"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { TabContext, TabPanel } from "@mui/lab"; -import { LeftSidePanelSettings } from "../molecules/LeftSidePanelSettings"; -import { RunSettings } from "../molecules/RunSettings"; +import { LeftSidePanelSettings } from "./LeftSidePanelSettings"; +import { RunSettings } from "../run/RunSettings"; const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => { getActiveWorkflow(id).then( diff --git a/src/components/molecules/LeftSidePanelContent.tsx b/src/components/recorder/LeftSidePanelContent.tsx similarity index 77% rename from src/components/molecules/LeftSidePanelContent.tsx rename to src/components/recorder/LeftSidePanelContent.tsx index 79750d8f..4a980505 100644 --- a/src/components/molecules/LeftSidePanelContent.tsx +++ b/src/components/recorder/LeftSidePanelContent.tsx @@ -5,9 +5,9 @@ import { WhereWhatPair, WorkflowFile } from "maxun-core"; import { useSocketStore } from "../../context/socket"; import { Add } from "@mui/icons-material"; import { Socket } from "socket.io-client"; -import { AddButton } from "../atoms/buttons/AddButton"; +import { AddButton } from "../ui/buttons/AddButton"; import { AddPair } from "../../api/workflow"; -import { GenericModal } from "../atoms/GenericModal"; +import { GenericModal } from "../ui/GenericModal"; import { PairEditForm } from "./PairEditForm"; import { Fab, Tooltip, Typography } from "@mui/material"; @@ -18,7 +18,7 @@ interface LeftSidePanelContentProps { handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void; } -export const LeftSidePanelContent = ({ workflow, updateWorkflow, recordingName, handleSelectPairForEdit}: LeftSidePanelContentProps) => { +export const LeftSidePanelContent = ({ workflow, updateWorkflow, recordingName, handleSelectPairForEdit }: LeftSidePanelContentProps) => { const [activeId, setActiveId] = React.useState(0); const [breakpoints, setBreakpoints] = React.useState([]); const [showEditModal, setShowEditModal] = useState(false); @@ -67,12 +67,12 @@ export const LeftSidePanelContent = ({ workflow, updateWorkflow, recordingName, return (
-
+
@@ -86,20 +86,20 @@ export const LeftSidePanelContent = ({ workflow, updateWorkflow, recordingName, />
- { - workflow.workflow.map((pair, i, workflow, ) => - handleBreakpointClick(i)} - isActive={ activeId === i + 1} - key={workflow.length - i} - index={workflow.length - i} - pair={pair} - updateWorkflow={updateWorkflow} - numberOfPairs={workflow.length} - handleSelectPairForEdit={handleSelectPairForEdit} - />) - } -
+ { + workflow.workflow.map((pair, i, workflow,) => + handleBreakpointClick(i)} + isActive={activeId === i + 1} + key={workflow.length - i} + index={workflow.length - i} + pair={pair} + updateWorkflow={updateWorkflow} + numberOfPairs={workflow.length} + handleSelectPairForEdit={handleSelectPairForEdit} + />) + } +
); }; diff --git a/src/components/recorder/LeftSidePanelSettings.tsx b/src/components/recorder/LeftSidePanelSettings.tsx new file mode 100644 index 00000000..87c73ce1 --- /dev/null +++ b/src/components/recorder/LeftSidePanelSettings.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Button, MenuItem, TextField, Typography } from "@mui/material"; +import { Dropdown } from "../ui/DropdownMui"; +import { RunSettings } from "../run/RunSettings"; +import { useSocketStore } from "../../context/socket"; + +interface LeftSidePanelSettingsProps { + params: any[] + settings: RunSettings, + setSettings: (setting: RunSettings) => void +} + +export const LeftSidePanelSettings = ({ params, settings, setSettings }: LeftSidePanelSettingsProps) => { + const { socket } = useSocketStore(); + + return ( +
+ {params.length !== 0 && ( + + Parameters: + {params?.map((item: string, index: number) => { + return setSettings( + { + ...settings, + params: settings.params + ? { + ...settings.params, + [item]: e.target.value, + } + : { + [item]: e.target.value, + }, + })} + /> + })} + + )} + Interpreter: + setSettings( + { + ...settings, + maxConcurrency: parseInt(e.target.value), + })} + defaultValue={settings.maxConcurrency} + /> + setSettings( + { + ...settings, + maxRepeats: parseInt(e.target.value), + })} + defaultValue={settings.maxRepeats} + /> + setSettings( + { + ...settings, + debug: e.target.value === "true", + })} + > + true + false + + +
+ ); +} diff --git a/src/components/molecules/Pair.tsx b/src/components/recorder/Pair.tsx similarity index 63% rename from src/components/molecules/Pair.tsx rename to src/components/recorder/Pair.tsx index 3c332600..12d2eca7 100644 --- a/src/components/molecules/Pair.tsx +++ b/src/components/recorder/Pair.tsx @@ -2,12 +2,12 @@ import React, { FC, useState } from 'react'; import { Stack, Button, IconButton, Tooltip, Badge } from "@mui/material"; import { AddPair, deletePair, UpdatePair } from "../../api/workflow"; import { WorkflowFile } from "maxun-core"; -import { ClearButton } from "../atoms/buttons/ClearButton"; -import { GenericModal } from "../atoms/GenericModal"; +import { ClearButton } from "../ui/buttons/ClearButton"; +import { GenericModal } from "../ui/GenericModal"; import { PairEditForm } from "./PairEditForm"; -import { PairDisplayDiv } from "../atoms/PairDisplayDiv"; -import { EditButton } from "../atoms/buttons/EditButton"; -import { BreakpointButton } from "../atoms/buttons/BreakpointButton"; +import { PairDisplayDiv } from "./PairDisplayDiv"; +import { EditButton } from "../ui/buttons/EditButton"; +import { BreakpointButton } from "../ui/buttons/BreakpointButton"; import VisibilityIcon from '@mui/icons-material/Visibility'; import styled from "styled-components"; import { LoadingButton } from "@mui/lab"; @@ -54,19 +54,19 @@ export const Pair: FC = ( }; const handleEdit = (pair: WhereWhatPair, newIndex: number) => { - if (newIndex !== index){ - AddPair((newIndex - 1), pair).then((updatedWorkflow) => { - updateWorkflow(updatedWorkflow); - }).catch((error) => { - console.error(error); - }); - } else { - UpdatePair((index - 1), pair).then((updatedWorkflow) => { - updateWorkflow(updatedWorkflow); - }).catch((error) => { - console.error(error); - }); - } + if (newIndex !== index) { + AddPair((newIndex - 1), pair).then((updatedWorkflow) => { + updateWorkflow(updatedWorkflow); + }).catch((error) => { + console.error(error); + }); + } else { + UpdatePair((index - 1), pair).then((updatedWorkflow) => { + updateWorkflow(updatedWorkflow); + }).catch((error) => { + console.error(error); + }); + } handleClose(); }; @@ -78,10 +78,10 @@ export const Pair: FC = ( return ( -
- {isActive ? - : breakpoint ? - : +
+ {isActive ? + : breakpoint ? + : }
@@ -92,53 +92,53 @@ export const Pair: FC = ( fontSize: '1rem', textTransform: 'none', }} variant='text' key={`pair-${index}`} - onClick={() => handleSelectPairForEdit(pair, index)}> + onClick={() => handleSelectPairForEdit(pair, index)}> index: {index} + "&:hover": { + color: 'inherit', + } + }}> -
- -
+
+ +
- { - enableEdit(); - handleOpen(); - }} - /> + { + enableEdit(); + handleOpen(); + }} + />
-
+
- +
- { edit + {edit ? - + :
= ( } - ); + ); }; interface ViewButtonProps { handleClick: () => void; } -const ViewButton = ({handleClick}: ViewButtonProps) => { +const ViewButton = ({ handleClick }: ViewButtonProps) => { return ( - + sx={{ color: 'inherit', '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}> + ); } const PairWrapper = styled.div<{ isActive: boolean }>` - background-color: ${({ isActive }) => isActive ? 'rgba(255, 0, 0, 0.1)' : 'transparent' }; - border: ${({ isActive }) => isActive ? 'solid 2px red' : 'none' }; + background-color: ${({ isActive }) => isActive ? 'rgba(255, 0, 0, 0.1)' : 'transparent'}; + border: ${({ isActive }) => isActive ? 'solid 2px red' : 'none'}; display: flex; flex-direction: row; flex-grow: 1; @@ -176,6 +176,6 @@ const PairWrapper = styled.div<{ isActive: boolean }>` color: gray; &:hover { color: dimgray; - background: ${({ isActive }) => isActive ? 'rgba(255, 0, 0, 0.1)' : 'transparent' }; + background: ${({ isActive }) => isActive ? 'rgba(255, 0, 0, 0.1)' : 'transparent'}; } `; diff --git a/src/components/recorder/PairDetail.tsx b/src/components/recorder/PairDetail.tsx new file mode 100644 index 00000000..d330db1d --- /dev/null +++ b/src/components/recorder/PairDetail.tsx @@ -0,0 +1,309 @@ +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { WhereWhatPair } from "maxun-core"; +import { Box, Button, IconButton, MenuItem, Stack, TextField, Tooltip, Typography } from "@mui/material"; +import { Close, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; +import TreeView from '@mui/lab/TreeView'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import TreeItem from '@mui/lab/TreeItem'; +import { AddButton } from "../ui/buttons/AddButton"; +import { WarningText } from "../ui/texts"; +import NotificationImportantIcon from '@mui/icons-material/NotificationImportant'; +import { RemoveButton } from "../ui/buttons/RemoveButton"; +import { AddWhereCondModal } from "./AddWhereCondModal"; +import { UpdatePair } from "../../api/workflow"; +import { useSocketStore } from "../../context/socket"; +import { AddWhatCondModal } from "./AddWhatCondModal"; + +interface PairDetailProps { + pair: WhereWhatPair | null; + index: number; +} + +export const PairDetail = ({ pair, index }: PairDetailProps) => { + const [pairIsSelected, setPairIsSelected] = useState(false); + const [collapseWhere, setCollapseWhere] = useState(true); + const [collapseWhat, setCollapseWhat] = useState(true); + const [rerender, setRerender] = useState(false); + const [expanded, setExpanded] = React.useState( + pair ? Object.keys(pair.where).map((key, index) => `${key}-${index}`) : [] + ); + const [addWhereCondOpen, setAddWhereCondOpen] = useState(false); + const [addWhatCondOpen, setAddWhatCondOpen] = useState(false); + + const { socket } = useSocketStore(); + + const handleCollapseWhere = () => { + setCollapseWhere(!collapseWhere); + } + + const handleCollapseWhat = () => { + setCollapseWhat(!collapseWhat); + } + + const handleToggle = (event: React.SyntheticEvent, nodeIds: string[]) => { + setExpanded(nodeIds); + }; + + useLayoutEffect(() => { + if (pair) { + setPairIsSelected(true); + } + }, [pair]) + + const handleChangeValue = (value: any, where: boolean, keys: (string | number)[]) => { + // a moving reference to internal objects within pair.where or pair.what + let schema: any = where ? pair?.where : pair?.what; + const length = keys.length; + for (let i = 0; i < length - 1; i++) { + const elem = keys[i]; + if (!schema[elem]) schema[elem] = {} + schema = schema[elem]; + } + + schema[keys[length - 1]] = value; + if (pair && socket) { + socket.emit('updatePair', { index: index - 1, pair: pair }); + } + setRerender(!rerender); + } + + + const DisplayValueContent = (value: any, keys: (string | number)[], where: boolean = true) => { + switch (typeof (value)) { + case 'string': + return { + try { + const obj = JSON.parse(e.target.value); + handleChangeValue(obj, where, keys); + } catch (error) { + const num = Number(e.target.value); + if (!isNaN(num)) { + handleChangeValue(num, where, keys); + } + handleChangeValue(e.target.value, where, keys) + } + }} + defaultValue={value} + key={`text-field-${keys.join('-')}-${where}`} + /> + case 'number': + return handleChangeValue(Number(e.target.value), where, keys)} + defaultValue={value} + key={`text-field-${keys.join('-')}-${where}`} + /> + case 'object': + if (value) { + if (Array.isArray(value)) { + return ( + + { + value.map((element, index) => { + return DisplayValueContent(element, [...keys, index], where); + }) + } + { + let prevValue: any = where ? pair?.where : pair?.what; + for (const key of keys) { + prevValue = prevValue[key]; + } + handleChangeValue([...prevValue, ''], where, keys); + setRerender(!rerender); + }} hoverEffect={false} /> + { + let prevValue: any = where ? pair?.where : pair?.what; + for (const key of keys) { + prevValue = prevValue[key]; + } + prevValue.splice(-1); + handleChangeValue(prevValue, where, keys); + setRerender(!rerender); + }} /> + + ) + } else { + return ( + } + defaultExpandIcon={} + sx={{ flexGrow: 1, overflowY: 'auto' }} + key={`tree-view-nested-${keys.join('-')}-${where}`} + > + { + Object.keys(value).map((key2, index) => { + return ( + + {DisplayValueContent(value[key2], [...keys, key2], where)} + + ) + }) + } + + ) + } + } + break; + default: + return null; + } + } + + return ( + + {pair && + + setAddWhatCondOpen(false)} + pair={pair} index={index} /> + setAddWhereCondOpen(false)} + pair={pair} index={index} /> + + } + { + pairIsSelected + ? ( +
+ Pair number: {index} + { + if (pair && socket) { + socket.emit('updatePair', { index: index - 1, pair: pair }); + pair.id = e.target.value; + } + }} + value={pair ? pair.id ? pair.id : '' : ''} + /> + + + Where + +
+ { + setAddWhereCondOpen(true); + }} style={{ color: 'rgba(0, 0, 0, 0.54)', background: 'transparent' }} /> +
+
+
+ {(collapseWhere && pair && pair.where) + ? + + {Object.keys(pair.where).map((key, index) => { + return ( + } + defaultExpandIcon={} + sx={{ flexGrow: 1, overflowY: 'auto' }} + onNodeToggle={handleToggle} + key={`tree-view-${key}-${index}`} + > + + { + // @ts-ignore + DisplayValueContent(pair.where[key], [key]) + } + + + ); + })} + + : null + } + + + What + + +
+ { + setAddWhatCondOpen(true); + }} style={{ color: 'rgba(0, 0, 0, 0.54)', background: 'transparent' }} /> +
+
+
+ {(collapseWhat && pair && pair.what) + ? ( + + {Object.keys(pair.what).map((key, index) => { + return ( + } + defaultExpandIcon={} + sx={{ flexGrow: 1, overflowY: 'auto' }} + key={`tree-view-2-${key}-${index}`} + > + + { + // @ts-ignore + DisplayValueContent(pair.what[key], [key], false) + } + +
+ { + //@ts-ignore + pair.what.splice(key, 1); + setRerender(!rerender); + }} /> +
+
+
+
+ ); + })} +
+ ) + : null + } +
+ ) + : + + No pair from the left side panel was selected. + + } +
+ ); +} + +interface CollapseButtonProps { + handleClick: () => void; + isCollapsed?: boolean; +} + +const CollapseButton = ({ handleClick, isCollapsed }: CollapseButtonProps) => { + return ( + + {isCollapsed ? : } + + ); +} + +const CloseButton = ({ handleClick }: CollapseButtonProps) => { + return ( + + + + ); +} diff --git a/src/components/atoms/PairDisplayDiv.tsx b/src/components/recorder/PairDisplayDiv.tsx similarity index 100% rename from src/components/atoms/PairDisplayDiv.tsx rename to src/components/recorder/PairDisplayDiv.tsx diff --git a/src/components/molecules/PairEditForm.tsx b/src/components/recorder/PairEditForm.tsx similarity index 59% rename from src/components/molecules/PairEditForm.tsx rename to src/components/recorder/PairEditForm.tsx index 7ab9c381..aa3354ec 100644 --- a/src/components/molecules/PairEditForm.tsx +++ b/src/components/recorder/PairEditForm.tsx @@ -56,30 +56,30 @@ export const PairEditForm: FC = ( event.preventDefault(); let whereFromPair, whatFromPair; // validate where - whereFromPair = { - where: pairProps.where && pairProps.where !== '{"url":"","selectors":[""] }' - ? JSON.parse(pairProps.where) - : {}, - what: [], - }; - const validationError = Preprocessor.validateWorkflow({workflow: [whereFromPair]}); - setErrors({ ...errors, where: null }); + whereFromPair = { + where: pairProps.where && pairProps.where !== '{"url":"","selectors":[""] }' + ? JSON.parse(pairProps.where) + : {}, + what: [], + }; + const validationError = Preprocessor.validateWorkflow({ workflow: [whereFromPair] }); + setErrors({ ...errors, where: null }); if (validationError) { setErrors({ ...errors, where: validationError.message }); return; } // validate what - whatFromPair = { - where: {}, - what: pairProps.what && pairProps.what !== '[{"action":"","args":[""] }]' - ? JSON.parse(pairProps.what): [], - }; - const validationErrorWhat = Preprocessor.validateWorkflow({workflow: [whatFromPair]}); - setErrors({ ...errors, "what": null }); - if (validationErrorWhat) { - setErrors({ ...errors, what: validationErrorWhat.message }); - return; - } + whatFromPair = { + where: {}, + what: pairProps.what && pairProps.what !== '[{"action":"","args":[""] }]' + ? JSON.parse(pairProps.what) : [], + }; + const validationErrorWhat = Preprocessor.validateWorkflow({ workflow: [whatFromPair] }); + setErrors({ ...errors, "what": null }); + if (validationErrorWhat) { + setErrors({ ...errors, what: validationErrorWhat.message }); + return; + } //validate index const index = parseInt(pairProps?.index, 10); if (index > (numberOfPairs + 1)) { @@ -99,18 +99,18 @@ export const PairEditForm: FC = ( } else { setErrors({ ...errors, index: '' }); } - // submit the pair - onSubmitOfPair(pairProps.id + // submit the pair + onSubmitOfPair(pairProps.id ? { id: pairProps.id, where: whereFromPair?.where || {}, what: whatFromPair?.what || [], } : { - where: whereFromPair?.where || {}, - what: whatFromPair?.what || [], - } - , index); + where: whereFromPair?.where || {}, + what: whatFromPair?.what || [], + } + , index); }; return ( @@ -122,33 +122,33 @@ export const PairEditForm: FC = ( marginTop: "36px", }} > - Raw pair edit form: + Raw pair edit form: - - + + } + {getList && ( <> @@ -476,6 +480,11 @@ export const RightSidePanel: React.FC = ({ onFinishCapture @@ -484,13 +493,26 @@ export const RightSidePanel: React.FC = ({ onFinishCapture variant="outlined" onClick={handleConfirmListCapture} disabled={captureStage === 'initial' ? isConfirmCaptureDisabled : hasUnconfirmedListTextFields} + sx={{ + color: '#ff00c3 !important', + borderColor: '#ff00c3 !important', + backgroundColor: 'whitesmoke !important', + }} > {captureStage === 'initial' ? t('right_panel.buttons.confirm_capture') : - captureStage === 'pagination' ? t('right_panel.buttons.confirm_pagination') : - captureStage === 'limit' ? t('right_panel.buttons.confirm_limit') : - t('right_panel.buttons.finish_capture')} + captureStage === 'pagination' ? t('right_panel.buttons.confirm_pagination') : + captureStage === 'limit' ? t('right_panel.buttons.confirm_limit') : + t('right_panel.buttons.finish_capture')} - @@ -499,11 +521,55 @@ export const RightSidePanel: React.FC = ({ onFinishCapture {showPaginationOptions && ( {t('right_panel.pagination.title')} - - - - - + + + + + )} {showLimitOptions && ( @@ -526,45 +592,94 @@ export const RightSidePanel: React.FC = ({ onFinishCapture } label={t('right_panel.limit.custom')} /> {limitType === 'custom' && ( updateCustomLimit(e.target.value)} - placeholder={t('right_panel.limit.enter_number')} - sx={{ - marginLeft: '10px', - '& input': { - padding: '10px', - background: 'white', - }, - width: '150px', // Ensure the text field does not go outside the panel - }} + type="number" + value={customLimit} + onChange={(e: React.ChangeEvent) => { + const value = parseInt(e.target.value); + // Only update if the value is greater than or equal to 1 or if the field is empty + if (e.target.value === '' || value >= 1) { + updateCustomLimit(e.target.value); + } + }} + inputProps={{ + min: 1, + onKeyPress: (e: React.KeyboardEvent) => { + const value = (e.target as HTMLInputElement).value + e.key; + if (parseInt(value) < 1) { + e.preventDefault(); + } + } + }} + placeholder={t('right_panel.limit.enter_number')} + sx={{ + marginLeft: '10px', + '& input': { + padding: '10px', + + }, + width: '150px', + background: isDarkMode ? "#1E2124" : 'white', + color: isDarkMode ? "white" : 'black', // Ensure the text field does not go outside the panel + }} /> )}
)} + {/* {!getText && !getScreenshot && !getList && showCaptureText && } */} + {!getText && !getScreenshot && !getList && showCaptureText && } {getText && <> - - + + } + {/* {!getText && !getScreenshot && !getList && showCaptureScreenshot && } */} {!getText && !getScreenshot && !getList && showCaptureScreenshot && } {getScreenshot && ( - + )} {browserSteps.map(step => ( - handleMouseEnter(step.id)} onMouseLeave={() => handleMouseLeave(step.id)} sx={{ padding: '10px', margin: '11px', borderRadius: '5px', position: 'relative', background: 'white' }}> + handleMouseEnter(step.id)} onMouseLeave={() => handleMouseLeave(step.id)} sx={{ padding: '10px', margin: '11px', borderRadius: '5px', position: 'relative', background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }}> { step.type === 'text' && ( <> @@ -585,6 +700,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture ) }} + sx={{ background: isDarkMode ? "#1E2124" : 'white', color: isDarkMode ? "white" : 'black' }} /> = ({ onFinishCapture ) }} + /> {!confirmedTextSteps[step.id] ? ( @@ -622,8 +739,8 @@ export const RightSidePanel: React.FC = ({ onFinishCapture - {step.fullPage ? - t('right_panel.screenshot.display_fullpage') : + {step.fullPage ? + t('right_panel.screenshot.display_fullpage') : t('right_panel.screenshot.display_visible')} @@ -632,7 +749,7 @@ export const RightSidePanel: React.FC = ({ onFinishCapture <> {t('right_panel.messages.list_selected')} {Object.entries(step.fields).map(([key, field]) => ( - + = ({ onFinishCapture ) }} + /> {!confirmedListTextFields[step.id]?.[key] ? ( diff --git a/src/components/molecules/SaveRecording.tsx b/src/components/recorder/SaveRecording.tsx similarity index 85% rename from src/components/molecules/SaveRecording.tsx rename to src/components/recorder/SaveRecording.tsx index 8e1eb462..835930e4 100644 --- a/src/components/molecules/SaveRecording.tsx +++ b/src/components/recorder/SaveRecording.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useEffect, useState, useContext } from 'react'; import { Button, Box, LinearProgress, Tooltip } from "@mui/material"; -import { GenericModal } from "../atoms/GenericModal"; +import { GenericModal } from "../ui/GenericModal"; import { stopRecording } from "../../api/recording"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { AuthContext } from '../../context/auth'; import { useSocketStore } from "../../context/socket"; import { TextField, Typography } from "@mui/material"; -import { WarningText } from "../atoms/texts"; +import { WarningText } from "../ui/texts"; import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -77,7 +77,21 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { return (
- @@ -101,12 +115,12 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { - {t('save_recording.warnings.robot_exists')} + {t('save_recording.errors.exists_warning')} ) : + {t('save_recording.buttons.save')} + } {waitingForSave && diff --git a/src/components/molecules/SidePanelHeader.tsx b/src/components/recorder/SidePanelHeader.tsx similarity index 90% rename from src/components/molecules/SidePanelHeader.tsx rename to src/components/recorder/SidePanelHeader.tsx index 9e93f432..41b33ae7 100644 --- a/src/components/molecules/SidePanelHeader.tsx +++ b/src/components/recorder/SidePanelHeader.tsx @@ -1,5 +1,5 @@ import React, { FC, useState } from 'react'; -import { InterpretationButtons } from "./InterpretationButtons"; +import { InterpretationButtons } from "../run/InterpretationButtons"; import { useSocketStore } from "../../context/socket"; export const SidePanelHeader = () => { diff --git a/src/components/atoms/canvas.tsx b/src/components/recorder/canvas.tsx similarity index 94% rename from src/components/atoms/canvas.tsx rename to src/components/recorder/canvas.tsx index e71a4d93..83e48274 100644 --- a/src/components/atoms/canvas.tsx +++ b/src/components/recorder/canvas.tsx @@ -3,10 +3,10 @@ import { useSocketStore } from '../../context/socket'; import { getMappedCoordinates } from "../../helpers/inputHelpers"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { useActionContext } from '../../context/browserActions'; -import DatePicker from './DatePicker'; -import Dropdown from './Dropdown'; -import TimePicker from './TimePicker'; -import DateTimeLocalPicker from './DateTimeLocalPicker'; +import DatePicker from '../pickers/DatePicker'; +import Dropdown from '../pickers/Dropdown'; +import TimePicker from '../pickers/TimePicker'; +import DateTimeLocalPicker from '../pickers/DateTimeLocalPicker'; interface CreateRefCallback { (ref: React.RefObject): void; @@ -76,7 +76,7 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { useEffect(() => { if (socket) { - socket.on('showDatePicker', (info: {coordinates: Coordinates, selector: string}) => { + socket.on('showDatePicker', (info: { coordinates: Coordinates, selector: string }) => { setDatePickerInfo(info); }); @@ -93,11 +93,11 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { setDropdownInfo(info); }); - socket.on('showTimePicker', (info: {coordinates: Coordinates, selector: string}) => { + socket.on('showTimePicker', (info: { coordinates: Coordinates, selector: string }) => { setTimePickerInfo(info); }); - socket.on('showDateTimePicker', (info: {coordinates: Coordinates, selector: string}) => { + socket.on('showDateTimePicker', (info: { coordinates: Coordinates, selector: string }) => { setDateTimeLocalInfo(info); }); diff --git a/src/components/organisms/Recordings.tsx b/src/components/robot/Recordings.tsx similarity index 90% rename from src/components/organisms/Recordings.tsx rename to src/components/robot/Recordings.tsx index 495a25f8..c05ea261 100644 --- a/src/components/organisms/Recordings.tsx +++ b/src/components/robot/Recordings.tsx @@ -1,12 +1,12 @@ import React, { useState } from 'react'; -import { RecordingsTable } from "../molecules/RecordingsTable"; +import { RecordingsTable } from "./RecordingsTable"; import { Grid } from "@mui/material"; -import { RunSettings, RunSettingsModal } from "../molecules/RunSettings"; -import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings"; -import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings"; -import { RobotSettings, RobotSettingsModal } from "../molecules/RobotSettings"; -import { RobotEditModal } from '../molecules/RobotEdit'; -import { RobotDuplicationModal } from '../molecules/RobotDuplicate'; +import { RunSettings, RunSettingsModal } from "../run/RunSettings"; +import { ScheduleSettings, ScheduleSettingsModal } from "./ScheduleSettings"; +import { IntegrationSettings, IntegrationSettingsModal } from "../integration/IntegrationSettings"; +import { RobotSettings, RobotSettingsModal } from "./RobotSettings"; +import { RobotEditModal } from './RobotEdit'; +import { RobotDuplicationModal } from './RobotDuplicate'; interface RecordingsProps { handleEditRecording: (id: string, fileName: string) => void; @@ -15,7 +15,7 @@ interface RecordingsProps { setRecordingInfo: (id: string, name: string) => void; } -export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordingInfo, handleScheduleRecording}: RecordingsProps) => { +export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordingInfo, handleScheduleRecording }: RecordingsProps) => { const [runSettingsAreOpen, setRunSettingsAreOpen] = useState(false); const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false); const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false); @@ -24,10 +24,10 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi const [robotDuplicateAreOpen, setRobotDuplicateAreOpen] = useState(false); const [params, setParams] = useState([]); const [selectedRecordingId, setSelectedRecordingId] = useState(''); - const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => {}; - const handleSettingsRecording = (id: string, settings: RobotSettings) => {}; - const handleEditRobot = (id: string, settings: RobotSettings) => {}; - const handleDuplicateRobot = (id: string, settings: RobotSettings) => {}; + const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => { }; + const handleSettingsRecording = (id: string, settings: RobotSettings) => { }; + const handleEditRobot = (id: string, settings: RobotSettings) => { }; + const handleDuplicateRobot = (id: string, settings: RobotSettings) => { }; const handleSettingsAndIntegrate = (id: string, name: string, params: string[]) => { if (params.length === 0) { @@ -169,9 +169,9 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi handleClose={handleRobotSettingsClose} handleStart={(settings) => handleSettingsRecording(selectedRecordingId, settings)} /> - handleEditRobot(selectedRecordingId,settings)} + handleStart={(settings) => handleEditRobot(selectedRecordingId, settings)} /> { - const {t} = useTranslation(); + const { t } = useTranslation(); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rows, setRows] = React.useState([]); @@ -406,7 +406,7 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut setAnchorEl(null); }; - const {t} = useTranslation(); + const { t } = useTranslation(); return ( <> diff --git a/src/components/molecules/RobotDuplicate.tsx b/src/components/robot/RobotDuplicate.tsx similarity index 87% rename from src/components/molecules/RobotDuplicate.tsx rename to src/components/robot/RobotDuplicate.tsx index ce3ee5ca..a78e523b 100644 --- a/src/components/molecules/RobotDuplicate.tsx +++ b/src/components/robot/RobotDuplicate.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { GenericModal } from "../atoms/GenericModal"; +import { GenericModal } from "../ui/GenericModal"; import { TextField, Typography, Box, Button } from "@mui/material"; -import { modalStyle } from "./AddWhereCondModal"; +import { modalStyle } from "../recorder/AddWhereCondModal"; import { useGlobalInfoStore } from '../../context/globalInfo'; import { duplicateRecording, getStoredRecording } from '../../api/storage'; import { WhereWhatPair } from 'maxun-core'; @@ -99,7 +99,7 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia if (success) { notify('success', t('robot_duplication.notifications.duplicate_success')); handleStart(robot); - handleClose(); + handleClose(); setTimeout(() => { window.location.reload(); @@ -136,7 +136,7 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia url1: 'producthunt.com/topics/api', url2: 'producthunt.com/topics/database' }) - }}/> + }} />
{t('robot_duplication.descriptions.warning')} @@ -152,7 +152,16 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia - diff --git a/src/components/molecules/RobotEdit.tsx b/src/components/robot/RobotEdit.tsx similarity index 89% rename from src/components/molecules/RobotEdit.tsx rename to src/components/robot/RobotEdit.tsx index 6547d93b..946af330 100644 --- a/src/components/molecules/RobotEdit.tsx +++ b/src/components/robot/RobotEdit.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { GenericModal } from "../atoms/GenericModal"; +import { GenericModal } from "../ui/GenericModal"; import { TextField, Typography, Box, Button } from "@mui/material"; -import { modalStyle } from "./AddWhereCondModal"; +import { modalStyle } from "../recorder/AddWhereCondModal"; import { useGlobalInfoStore } from '../../context/globalInfo'; import { getStoredRecording, updateRecording } from '../../api/storage'; import { WhereWhatPair } from 'maxun-core'; @@ -118,7 +118,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin if (success) { notify('success', t('robot_edit.notifications.update_success')); handleStart(robot); // Inform parent about the updated robot - handleClose(); + handleClose(); setTimeout(() => { window.location.reload(); @@ -159,11 +159,11 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin label={t('robot_edit.robot_limit')} type="number" value={robot.recording.workflow[0].what[0].args[0].limit || ''} - onChange={(e) =>{ + onChange={(e) => { const value = parseInt(e.target.value, 10); if (value >= 1) { handleLimitChange(value); - } + } }} inputProps={{ min: 1 }} style={{ marginBottom: '20px' }} @@ -174,12 +174,16 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin - diff --git a/src/components/molecules/RobotSettings.tsx b/src/components/robot/RobotSettings.tsx similarity index 98% rename from src/components/molecules/RobotSettings.tsx rename to src/components/robot/RobotSettings.tsx index d952f43d..2dd7ebe3 100644 --- a/src/components/molecules/RobotSettings.tsx +++ b/src/components/robot/RobotSettings.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { GenericModal } from "../atoms/GenericModal"; +import { GenericModal } from "../ui/GenericModal"; import { TextField, Typography, Box } from "@mui/material"; -import { modalStyle } from "./AddWhereCondModal"; +import { modalStyle } from "../recorder/AddWhereCondModal"; import { useGlobalInfoStore } from '../../context/globalInfo'; import { getStoredRecording } from '../../api/storage'; import { WhereWhatPair } from 'maxun-core'; diff --git a/src/components/molecules/ScheduleSettings.tsx b/src/components/robot/ScheduleSettings.tsx similarity index 95% rename from src/components/molecules/ScheduleSettings.tsx rename to src/components/robot/ScheduleSettings.tsx index 917696c9..658ce594 100644 --- a/src/components/molecules/ScheduleSettings.tsx +++ b/src/components/robot/ScheduleSettings.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { GenericModal } from "../atoms/GenericModal"; +import { GenericModal } from "../ui/GenericModal"; import { MenuItem, TextField, Typography, Box } from "@mui/material"; -import { Dropdown } from "../atoms/DropdownMui"; +import { Dropdown } from "../ui/DropdownMui"; import Button from "@mui/material/Button"; import { validMomentTimezones } from '../../constants/const'; import { useGlobalInfoStore } from '../../context/globalInfo'; @@ -123,12 +123,12 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia if (!day) return ''; const lastDigit = day.slice(-1); const lastTwoDigits = day.slice(-2); - + // Special cases for 11, 12, 13 if (['11', '12', '13'].includes(lastTwoDigits)) { return t('schedule_settings.labels.on_day.th'); } - + // Other cases switch (lastDigit) { case '1': return t('schedule_settings.labels.on_day.st'); @@ -273,7 +273,16 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia - diff --git a/src/components/molecules/ToggleButton.tsx b/src/components/robot/ToggleButton.tsx similarity index 94% rename from src/components/molecules/ToggleButton.tsx rename to src/components/robot/ToggleButton.tsx index a0917427..db10e645 100644 --- a/src/components/molecules/ToggleButton.tsx +++ b/src/components/robot/ToggleButton.tsx @@ -8,8 +8,8 @@ interface ToggleButtonProps { export const ToggleButton: FC = ({ isChecked = false, onChange }) => ( - - + + ); diff --git a/src/components/molecules/ColapsibleRow.tsx b/src/components/run/ColapsibleRow.tsx similarity index 92% rename from src/components/molecules/ColapsibleRow.tsx rename to src/components/run/ColapsibleRow.tsx index 5e4be9da..9612458b 100644 --- a/src/components/molecules/ColapsibleRow.tsx +++ b/src/components/run/ColapsibleRow.tsx @@ -7,8 +7,8 @@ import { DeleteForever, KeyboardArrowDown, KeyboardArrowUp, Settings } from "@mu import { deleteRunFromStorage } from "../../api/storage"; import { columns, Data } from "./RunsTable"; import { RunContent } from "./RunContent"; -import { GenericModal } from "../atoms/GenericModal"; -import { modalStyle } from "./AddWhereCondModal"; +import { GenericModal } from "../ui/GenericModal"; +import { modalStyle } from "../recorder/AddWhereCondModal"; import { getUserById } from "../../api/auth"; import { useTranslation } from "react-i18next"; @@ -103,7 +103,7 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun } else { switch (column.id) { case 'runStatus': - return ( + return ( {row.status === 'success' && } {row.status === 'running' && } @@ -148,11 +148,11 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun /> {t('runs_table.run_settings_modal.labels.run_type')}: - diff --git a/src/components/molecules/InterpretationButtons.tsx b/src/components/run/InterpretationButtons.tsx similarity index 98% rename from src/components/molecules/InterpretationButtons.tsx rename to src/components/run/InterpretationButtons.tsx index 624a57b4..0edcf682 100644 --- a/src/components/molecules/InterpretationButtons.tsx +++ b/src/components/run/InterpretationButtons.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { interpretCurrentRecording, stopCurrentInterpretation } from "../../api/recording"; import { useSocketStore } from "../../context/socket"; import { useGlobalInfoStore } from "../../context/globalInfo"; -import { GenericModal } from "../atoms/GenericModal"; +import { GenericModal } from "../ui/GenericModal"; import { WhereWhatPair } from "maxun-core"; import HelpIcon from '@mui/icons-material/Help'; import { useTranslation } from "react-i18next"; @@ -79,7 +79,7 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP - {t('interpretation_buttons.modal.previous_action')} {decisionModal.action}, + {t('interpretation_buttons.modal.previous_action')} {decisionModal.action}, {t('interpretation_buttons.modal.element_text')} {decisionModal.innerText} diff --git a/src/components/molecules/InterpretationLog.tsx b/src/components/run/InterpretationLog.tsx similarity index 96% rename from src/components/molecules/InterpretationLog.tsx rename to src/components/run/InterpretationLog.tsx index 227e621c..925a95ba 100644 --- a/src/components/molecules/InterpretationLog.tsx +++ b/src/components/run/InterpretationLog.tsx @@ -15,8 +15,9 @@ import TableRow from '@mui/material/TableRow'; import Paper from '@mui/material/Paper'; import StorageIcon from '@mui/icons-material/Storage'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import { SidePanelHeader } from './SidePanelHeader'; +import { SidePanelHeader } from '../recorder/SidePanelHeader'; import { useGlobalInfoStore } from '../../context/globalInfo'; +import { useThemeMode } from '../../context/theme-provider'; import { useTranslation } from 'react-i18next'; interface InterpretationLogProps { @@ -81,7 +82,7 @@ export const InterpretationLog: React.FC = ({ isOpen, se setLog((prevState) => prevState + '\n' + t('interpretation_log.data_sections.binary_received') + '\n' - + t('interpretation_log.data_sections.mimetype') + mimetype + '\n' + + t('interpretation_log.data_sections.mimetype') + mimetype + '\n' + t('interpretation_log.data_sections.image_below') + '\n' + t('interpretation_log.data_sections.separator')); @@ -124,6 +125,8 @@ export const InterpretationLog: React.FC = ({ isOpen, se } }, [hasScrapeListAction, hasScrapeSchemaAction, hasScreenshotAction, setIsOpen]); + const { darkMode } = useThemeMode(); + return ( @@ -147,7 +150,7 @@ export const InterpretationLog: React.FC = ({ isOpen, se }, }} > - + {t('interpretation_log.titles.output_preview')} = ({ isOpen, se onOpen={toggleDrawer(true)} PaperProps={{ sx: { - background: 'white', - color: 'black', + background: `${darkMode ? '#1e2124' : 'white'}`, + color: `${darkMode ? 'white' : 'black'}`, padding: '10px', height: 500, width: width - 10, @@ -168,7 +171,7 @@ export const InterpretationLog: React.FC = ({ isOpen, se }} > - + {t('interpretation_log.titles.output_preview')}
- setTab(newTab)} aria-label="run-content-tabs"> - - + setTab(newTab)} + aria-label="run-content-tabs" + sx={{ + // Remove the default blue indicator + '& .MuiTabs-indicator': { + backgroundColor: '#FF00C3', // Change to pink + }, + // Remove default transition effects + '& .MuiTab-root': { + '&.Mui-selected': { + color: '#FF00C3', + }, + } + }} + > + theme.palette.mode === 'dark' ? '#fff' : '#000', + '&:hover': { + color: '#FF00C3' + }, + '&.Mui-selected': { + color: '#FF00C3', + } + }} + /> + theme.palette.mode === 'dark' ? '#fff' : '#000', + '&:hover': { + color: '#FF00C3' + }, + '&.Mui-selected': { + color: '#FF00C3', + } + }} + /> @@ -161,6 +201,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe background: 'rgba(0,0,0,0.06)', maxHeight: '300px', overflow: 'scroll', + backgroundColor: '#19171c' }}>
                     {JSON.stringify(row.serializableOutput, null, 2)}
diff --git a/src/components/molecules/RunSettings.tsx b/src/components/run/RunSettings.tsx
similarity index 95%
rename from src/components/molecules/RunSettings.tsx
rename to src/components/run/RunSettings.tsx
index 76b2c4d7..a35d2f28 100644
--- a/src/components/molecules/RunSettings.tsx
+++ b/src/components/run/RunSettings.tsx
@@ -1,9 +1,9 @@
 import React, { useState } from 'react';
-import { GenericModal } from "../atoms/GenericModal";
+import { GenericModal } from "../ui/GenericModal";
 import { MenuItem, TextField, Typography, Switch, FormControlLabel } from "@mui/material";
-import { Dropdown } from "../atoms/DropdownMui";
+import { Dropdown } from "../ui/DropdownMui";
 import Button from "@mui/material/Button";
-import { modalStyle } from "./AddWhereCondModal";
+import { modalStyle } from "../recorder/AddWhereCondModal";
 
 interface RunSettingsProps {
   isOpen: boolean;
diff --git a/src/components/organisms/Runs.tsx b/src/components/run/Runs.tsx
similarity index 84%
rename from src/components/organisms/Runs.tsx
rename to src/components/run/Runs.tsx
index e3927fd4..cedbf348 100644
--- a/src/components/organisms/Runs.tsx
+++ b/src/components/run/Runs.tsx
@@ -1,6 +1,6 @@
 import React, { useEffect } from 'react';
 import { Grid } from "@mui/material";
-import { RunsTable } from "../molecules/RunsTable";
+import { RunsTable } from "./RunsTable";
 
 interface RunsProps {
   currentInterpretationLog: string;
@@ -13,7 +13,7 @@ export const Runs = (
   { currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsProps) => {
 
   return (
-    
+    
       
          = ({ 
-  currentInterpretationLog, 
-  abortRunHandler, 
-  runId, 
-  runningRecordingName 
+export const RunsTable: React.FC = ({
+  currentInterpretationLog,
+  abortRunHandler,
+  runId,
+  runningRecordingName
 }) => {
   const { t } = useTranslation();
 
diff --git a/src/components/atoms/AlertSnackbar.tsx b/src/components/ui/AlertSnackbar.tsx
similarity index 100%
rename from src/components/atoms/AlertSnackbar.tsx
rename to src/components/ui/AlertSnackbar.tsx
diff --git a/src/components/atoms/Box.tsx b/src/components/ui/Box.tsx
similarity index 100%
rename from src/components/atoms/Box.tsx
rename to src/components/ui/Box.tsx
diff --git a/src/components/atoms/ConfirmationBox.tsx b/src/components/ui/ConfirmationBox.tsx
similarity index 100%
rename from src/components/atoms/ConfirmationBox.tsx
rename to src/components/ui/ConfirmationBox.tsx
diff --git a/src/components/atoms/DropdownMui.tsx b/src/components/ui/DropdownMui.tsx
similarity index 100%
rename from src/components/atoms/DropdownMui.tsx
rename to src/components/ui/DropdownMui.tsx
diff --git a/src/components/atoms/GenericModal.tsx b/src/components/ui/GenericModal.tsx
similarity index 100%
rename from src/components/atoms/GenericModal.tsx
rename to src/components/ui/GenericModal.tsx
diff --git a/src/components/atoms/Loader.tsx b/src/components/ui/Loader.tsx
similarity index 79%
rename from src/components/atoms/Loader.tsx
rename to src/components/ui/Loader.tsx
index 9d376bc7..0924b32f 100644
--- a/src/components/atoms/Loader.tsx
+++ b/src/components/ui/Loader.tsx
@@ -1,11 +1,14 @@
 import styled from "styled-components";
 import { Stack } from "@mui/material";
+import { useThemeMode } from "../../context/theme-provider";
 
 interface LoaderProps {
   text: string;
 }
 
 export const Loader: React.FC = ({ text }) => {
+  const { darkMode } = useThemeMode();
+
   return (
     
       
@@ -14,15 +17,19 @@ export const Loader: React.FC = ({ text }) => {
         
         
       
-      {text}
+      {text}
     
   );
 };
 
-const StyledParagraph = styled.p`
+interface StyledParagraphProps {
+  darkMode: boolean;
+}
+
+const StyledParagraph = styled.p`
   font-size: large;
   font-family: inherit;
-  color: #333;
+  color: ${({ darkMode }) => (darkMode ? 'white' : '#333')};
   margin-top: 20px;
 `;
 
diff --git a/src/components/atoms/buttons/AddButton.tsx b/src/components/ui/buttons/AddButton.tsx
similarity index 100%
rename from src/components/atoms/buttons/AddButton.tsx
rename to src/components/ui/buttons/AddButton.tsx
diff --git a/src/components/atoms/buttons/BreakpointButton.tsx b/src/components/ui/buttons/BreakpointButton.tsx
similarity index 100%
rename from src/components/atoms/buttons/BreakpointButton.tsx
rename to src/components/ui/buttons/BreakpointButton.tsx
diff --git a/src/components/atoms/buttons/ClearButton.tsx b/src/components/ui/buttons/ClearButton.tsx
similarity index 100%
rename from src/components/atoms/buttons/ClearButton.tsx
rename to src/components/ui/buttons/ClearButton.tsx
diff --git a/src/components/atoms/buttons/EditButton.tsx b/src/components/ui/buttons/EditButton.tsx
similarity index 100%
rename from src/components/atoms/buttons/EditButton.tsx
rename to src/components/ui/buttons/EditButton.tsx
diff --git a/src/components/atoms/buttons/RemoveButton.tsx b/src/components/ui/buttons/RemoveButton.tsx
similarity index 100%
rename from src/components/atoms/buttons/RemoveButton.tsx
rename to src/components/ui/buttons/RemoveButton.tsx
diff --git a/src/components/atoms/buttons/buttons.tsx b/src/components/ui/buttons/buttons.tsx
similarity index 66%
rename from src/components/atoms/buttons/buttons.tsx
rename to src/components/ui/buttons/buttons.tsx
index afc4a483..50b630f5 100644
--- a/src/components/atoms/buttons/buttons.tsx
+++ b/src/components/ui/buttons/buttons.tsx
@@ -1,26 +1,18 @@
 import styled from 'styled-components';
+import { useThemeMode } from '../../../context/theme-provider';
 
-export const NavBarButton = styled.button<{ disabled: boolean }>`
+export const NavBarButton = styled.button<{ disabled: boolean, mode: 'light' | 'dark' }>`
     margin-left: 10px;
     margin-right: 5px;
     padding: 0;
     border: none;
-    background-color: transparent;
+    background-color: ${mode => mode ? '#333' : '#ffffff'};
     cursor: ${({ disabled }) => disabled ? 'default' : 'pointer'};
     width: 24px;
     height: 24px;
     border-radius: 12px;
     outline: none;
-    color: ${({ disabled }) => disabled ? '#999' : '#333'};
-
-    ${({ disabled }) => disabled ? null : `
-        &:hover {
-            background-color: #ddd;
-        }
-       &:active {
-           background-color: #d0d0d0;
-       }
-    `};
+    color: ${mode => mode ? '#ffffff' : '#333333'};
 `;
 
 export const UrlFormButton = styled.button`
diff --git a/src/components/atoms/form.tsx b/src/components/ui/form.tsx
similarity index 100%
rename from src/components/atoms/form.tsx
rename to src/components/ui/form.tsx
diff --git a/src/components/atoms/texts.tsx b/src/components/ui/texts.tsx
similarity index 100%
rename from src/components/atoms/texts.tsx
rename to src/components/ui/texts.tsx
diff --git a/src/context/browserSteps.tsx b/src/context/browserSteps.tsx
index dd211199..fd311a35 100644
--- a/src/context/browserSteps.tsx
+++ b/src/context/browserSteps.tsx
@@ -32,6 +32,7 @@ export interface SelectorObject {
     selector: string;
     tag?: string;
     attribute?: string;
+    shadow?: boolean;
     [key: string]: any;
 }
 
diff --git a/src/context/globalInfo.tsx b/src/context/globalInfo.tsx
index e7ffed6e..92ce7e62 100644
--- a/src/context/globalInfo.tsx
+++ b/src/context/globalInfo.tsx
@@ -1,5 +1,5 @@
 import React, { createContext, useContext, useState } from "react";
-import { AlertSnackbarProps } from "../components/atoms/AlertSnackbar";
+import { AlertSnackbarProps } from "../components/ui/AlertSnackbar";
 
 
 interface GlobalInfo {
diff --git a/src/context/theme-provider.tsx b/src/context/theme-provider.tsx
new file mode 100644
index 00000000..d53ced24
--- /dev/null
+++ b/src/context/theme-provider.tsx
@@ -0,0 +1,256 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+import { ThemeProvider, createTheme } from '@mui/material/styles';
+import CssBaseline from '@mui/material/CssBaseline';
+
+const lightTheme = createTheme({
+  palette: {
+    primary: {
+      main: "#ff00c3",
+      contrastText: "#ffffff",
+    },
+  },
+  components: {
+    MuiButton: {
+      styleOverrides: {
+        root: {
+          // Default styles for all buttons (optional)
+          textTransform: "none",
+        },
+        containedPrimary: {
+          // Styles for 'contained' variant with 'primary' color
+          "&:hover": {
+            backgroundColor: "#ff66d9",
+          },
+        },
+        outlined: {
+          // Apply white background for all 'outlined' variant buttons
+          backgroundColor: "#ffffff",
+          "&:hover": {
+            backgroundColor: "#f0f0f0", // Optional lighter background on hover
+          },
+        },
+      },
+    },
+    MuiLink: {
+      styleOverrides: {
+        root: {
+          "&:hover": {
+            color: "#ff00c3",
+          },
+        },
+      },
+    },
+    MuiIconButton: {
+      styleOverrides: {
+        root: {
+          // '&:hover': {
+          //   color: "#ff66d9",
+          // },
+        },
+      },
+    },
+    MuiTab: {
+      styleOverrides: {
+        root: {
+          textTransform: "none",
+        },
+      },
+    },
+    MuiAlert: {
+      styleOverrides: {
+        standardInfo: {
+          backgroundColor: "#fce1f4",
+          color: "#ff00c3",
+          "& .MuiAlert-icon": {
+            color: "#ff00c3",
+          },
+        },
+      },
+    },
+    MuiAlertTitle: {
+      styleOverrides: {
+        root: {
+          "& .MuiAlert-icon": {
+            color: "#ffffff",
+          },
+        },
+      },
+    },
+  },
+});
+
+const darkTheme = createTheme({
+  palette: {
+    mode: 'dark',
+    primary: {
+      main: "#ff00c3",
+      contrastText: "#ffffff",
+    },
+    background: {
+      default: '#121212',
+      paper: '#1e1e1e',
+    },
+    text: {
+      primary: '#ffffff',
+      secondary: '#b3b3b3',
+    },
+  },
+  components: {
+    MuiButton: {
+      styleOverrides: {
+        root: {
+          textTransform: "none",
+          color: '#ffffff',
+          '&.MuiButton-outlined': {
+            borderColor: '#ffffff',
+            color: '#ffffff',
+            "&:hover": {
+              borderColor: '#ffffff',
+              backgroundColor: 'rgba(255, 255, 255, 0.08)',
+            },
+          },
+        },
+        containedPrimary: {
+          "&:hover": {
+            backgroundColor: "#ff66d9",
+          },
+        },
+        outlined: {
+          // Dark mode outlined buttons
+          backgroundColor: '#1e1e1e',
+          borderColor: '#ff00c3',
+          color: '#ff00c3',
+          "&:hover": {
+            backgroundColor: 'rgba(255, 0, 195, 0.08)',
+            borderColor: '#ff66d9',
+          },
+        },
+      },
+    },
+    MuiLink: {
+      styleOverrides: {
+        root: {
+          color: '#ff66d9',
+          "&:hover": {
+            color: "#ff00c3",
+          },
+        },
+      },
+    },
+    MuiIconButton: {
+      styleOverrides: {
+        root: {
+          color: '#ffffff',
+          "&:hover": {
+            backgroundColor: 'rgba(255, 0, 195, 0.08)',
+          },
+        },
+      },
+    },
+    MuiTab: {
+      styleOverrides: {
+        root: {
+          textTransform: "none",
+          color: '#ffffff',
+          "&.Mui-selected": {
+            color: '#ff00c3',
+          },
+        },
+      },
+    },
+    MuiAlert: {
+      styleOverrides: {
+        standardInfo: {
+          backgroundColor: "rgba(255, 0, 195, 0.15)",
+          color: "#ff66d9",
+          "& .MuiAlert-icon": {
+            color: "#ff66d9",
+          },
+        },
+      },
+    },
+    MuiAlertTitle: {
+      styleOverrides: {
+        root: {
+          "& .MuiAlert-icon": {
+            color: "#ff66d9",
+          },
+        },
+      },
+    },
+    // Additional dark mode specific components
+    MuiPaper: {
+      styleOverrides: {
+        root: {
+          backgroundColor: '#1e1e1e',
+        },
+      },
+    },
+    MuiAppBar: {
+      styleOverrides: {
+        root: {
+          backgroundColor: '#121212',
+        },
+      },
+    },
+    MuiDrawer: {
+      styleOverrides: {
+        paper: {
+          backgroundColor: '#121212',
+        },
+      },
+    },
+    MuiTableCell: {
+      styleOverrides: {
+        root: {
+          borderBottom: '1px solid rgba(255, 255, 255, 0.12)',
+        },
+      },
+    },
+    MuiDivider: {
+      styleOverrides: {
+        root: {
+          borderColor: 'rgba(255, 255, 255, 0.12)',
+        },
+      },
+    },
+  },
+});
+
+const ThemeModeContext = createContext({
+  toggleTheme: () => {},
+  darkMode: false,
+});
+
+export const useThemeMode = () => useContext(ThemeModeContext);
+
+const ThemeModeProvider = ({ children }: { children: React.ReactNode }) => {
+  // Load saved mode from localStorage or default to light mode
+  const [darkMode, setDarkMode] = useState(() => {
+    const savedMode = localStorage.getItem('darkMode');
+    return savedMode ? JSON.parse(savedMode) : false;
+  });
+
+  const toggleTheme = () => {
+    setDarkMode((prevMode: any) => {
+      const newMode = !prevMode;
+      localStorage.setItem('darkMode', JSON.stringify(newMode)); // Save new mode to localStorage
+      return newMode;
+    });
+  };
+
+  useEffect(() => {
+    localStorage.setItem('darkMode', JSON.stringify(darkMode)); // Save initial mode
+  }, [darkMode]);
+
+  return (
+    
+      
+        
+        {children}
+      
+    
+  );
+};
+
+export default ThemeModeProvider;
diff --git a/src/helpers/inputHelpers.ts b/src/helpers/inputHelpers.ts
index d7861734..4d81dc59 100644
--- a/src/helpers/inputHelpers.ts
+++ b/src/helpers/inputHelpers.ts
@@ -4,7 +4,7 @@ import {
   VIEWPORT_W,
   VIEWPORT_H,
 } from "../constants/const";
-import { Coordinates } from '../components/atoms/canvas';
+import { Coordinates } from '../components/recorder/canvas';
 
 export const throttle = (callback: any, limit: number) => {
   let wait = false;
diff --git a/src/index.css b/src/index.css
index 721e4d67..252b737a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -11,6 +11,7 @@ body {
   padding: 0;
   scrollbar-gutter: stable;
   overflow-y: auto;
+  
 }
 
 html {
@@ -43,6 +44,7 @@ code {
   align-items: center;
   overflow: hidden;
   position: relative;
+  
 }
 
 #browser-content {
@@ -54,6 +56,11 @@ code {
   transform-origin: top left; /* Keep the position fixed */
 }
 
+
+#browser {
+ 
+}
+
 #browser-window {
   overflow-y: auto;
   height: 100%;
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index ba0f377e..1b34a1db 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -1,12 +1,13 @@
 import axios from "axios";
-import { useState, useContext, useEffect, FormEvent } from "react";
+import { useState, useContext, useEffect } from "react";
 import { useNavigate, Link } from "react-router-dom";
 import { AuthContext } from "../context/auth";
-import { Box, Typography, TextField, Button, CircularProgress, Grid } from "@mui/material";
+import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material";
 import { useGlobalInfoStore } from "../context/globalInfo";
 import { apiUrl } from "../apiConfig";
 import { useTranslation } from 'react-i18next';
 import i18n from '../i18n';
+import { useThemeMode } from "../context/theme-provider";
 
 const Login = () => {
   const { t } = useTranslation();
@@ -17,12 +18,14 @@ const Login = () => {
     email: "",
     password: "",
   });
+
   const [loading, setLoading] = useState(false);
   const { notify } = useGlobalInfoStore();
   const { email, password } = form;
 
   const { state, dispatch } = useContext(AuthContext);
   const { user } = state;
+  const { darkMode } = useThemeMode();
 
   const navigate = useNavigate();
 
@@ -41,10 +44,11 @@ const Login = () => {
     e.preventDefault();
     setLoading(true);
     try {
-      const { data } = await axios.post(`${apiUrl}/auth/login`, {
-        email,
-        password,
-      });
+      const { data } = await axios.post(
+        `${apiUrl}/auth/login`,
+        { email, password },
+        { withCredentials: true }
+      );
       dispatch({ type: "LOGIN", payload: data });
       notify("success", t('login.welcome_notification'));
       window.localStorage.setItem("user", JSON.stringify(data));
@@ -64,6 +68,7 @@ const Login = () => {
         maxHeight: "100vh",
         mt: 6,
         padding: 4,
+        backgroundColor: darkMode ? "#121212" : "#ffffff",
       }}
     >
        {
         onSubmit={submitForm}
         sx={{
           textAlign: "center",
-          backgroundColor: "#ffffff",
+          backgroundColor: darkMode ? "#1e1e1e" : "#ffffff",
+          color: darkMode ? "#ffffff" : "#333333",
           padding: 6,
           borderRadius: 5,
           boxShadow: "0px 20px 40px rgba(0, 0, 0, 0.2), 0px -5px 10px rgba(0, 0, 0, 0.15)",
           display: "flex",
           flexDirection: "column",
           alignItems: "center",
-          maxWidth: 400,
+          maxWidth: 500,
           width: "100%",
         }}
       >
@@ -112,7 +118,10 @@ const Login = () => {
           fullWidth
           variant="contained"
           color="primary"
-          sx={{ mt: 2, mb: 2 }}
+          sx={{
+            mt: 2,
+            mb: 2,
+          }}
           disabled={loading || !email || !password}
         >
           {loading ? (
diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx
index 4a82170e..971e1259 100644
--- a/src/pages/MainPage.tsx
+++ b/src/pages/MainPage.tsx
@@ -1,19 +1,19 @@
 import React, { useCallback, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
-import { MainMenu } from "../components/organisms/MainMenu";
+import { MainMenu } from "../components/dashboard/MainMenu";
 import { Stack } from "@mui/material";
-import { Recordings } from "../components/organisms/Recordings";
-import { Runs } from "../components/organisms/Runs";
-import ProxyForm from '../components/organisms/ProxyForm';
-import ApiKey from '../components/organisms/ApiKey';
+import { Recordings } from "../components/robot/Recordings";
+import { Runs } from "../components/run/Runs";
+import ProxyForm from '../components/proxy/ProxyForm';
+import ApiKey from '../components/api/ApiKey';
 import { useGlobalInfoStore } from "../context/globalInfo";
 import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage";
 import { io, Socket } from "socket.io-client";
 import { stopRecording } from "../api/recording";
-import { RunSettings } from "../components/molecules/RunSettings";
-import { ScheduleSettings } from "../components/molecules/ScheduleSettings";
-import { IntegrationSettings } from "../components/molecules/IntegrationSettings";
-import { RobotSettings } from "../components/molecules/RobotSettings";
+import { RunSettings } from "../components/run/RunSettings";
+import { ScheduleSettings } from "../components/robot/ScheduleSettings";
+import { IntegrationSettings } from "../components/integration/IntegrationSettings";
+import { RobotSettings } from "../components/robot/RobotSettings";
 import { apiUrl } from "../apiConfig";
 
 interface MainPageProps {
diff --git a/src/pages/PageWrappper.tsx b/src/pages/PageWrappper.tsx
index 87157349..cb1b382e 100644
--- a/src/pages/PageWrappper.tsx
+++ b/src/pages/PageWrappper.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect, useState } from 'react';
-import { NavBar } from "../components/molecules/NavBar";
+import { NavBar } from "../components/dashboard/NavBar";
 import { SocketProvider } from "../context/socket";
 import { BrowserDimensionsProvider } from "../context/browserDimensions";
 import { AuthProvider } from '../context/auth';
@@ -7,11 +7,12 @@ import { RecordingPage } from "./RecordingPage";
 import { MainPage } from "./MainPage";
 import { useGlobalInfoStore } from "../context/globalInfo";
 import { getActiveBrowserId } from "../api/recording";
-import { AlertSnackbar } from "../components/atoms/AlertSnackbar";
+import { AlertSnackbar } from "../components/ui/AlertSnackbar";
 import Login from './Login';
 import Register from './Register';
 import UserRoute from '../routes/userRoute';
 import { Routes, Route, useNavigate } from 'react-router-dom';
+import { AppBar } from '@mui/material';
 
 export const PageWrapper = () => {
   const [open, setOpen] = useState(false);
@@ -50,7 +51,9 @@ export const PageWrapper = () => {
       
         
           
+         
             {!browserId && }
+           
             
               }>
                 } />
diff --git a/src/pages/RecordingPage.tsx b/src/pages/RecordingPage.tsx
index 82fa7e32..259e6a0d 100644
--- a/src/pages/RecordingPage.tsx
+++ b/src/pages/RecordingPage.tsx
@@ -1,11 +1,11 @@
 import React, { useCallback, useEffect, useState } from 'react';
 import { Grid } from '@mui/material';
-import { BrowserContent } from "../components/organisms/BrowserContent";
-import { InterpretationLog } from "../components/molecules/InterpretationLog";
+import { BrowserContent } from "../components/browser/BrowserContent";
+import { InterpretationLog } from "../components/run/InterpretationLog";
 import { startRecording, getActiveBrowserId } from "../api/recording";
-import { LeftSidePanel } from "../components/organisms/LeftSidePanel";
-import { RightSidePanel } from "../components/organisms/RightSidePanel";
-import { Loader } from "../components/atoms/Loader";
+import { LeftSidePanel } from "../components/recorder/LeftSidePanel";
+import { RightSidePanel } from "../components/recorder/RightSidePanel";
+import { Loader } from "../components/ui/Loader";
 import { useSocketStore } from "../context/socket";
 import { useBrowserDimensionsStore } from "../context/browserDimensions";
 import { ActionProvider } from "../context/browserActions"
@@ -14,7 +14,8 @@ import { useGlobalInfoStore } from "../context/globalInfo";
 import { editRecordingFromStorage } from "../api/storage";
 import { WhereWhatPair } from "maxun-core";
 import styled from "styled-components";
-import BrowserRecordingSave from '../components/molecules/BrowserRecordingSave';
+import BrowserRecordingSave from '../components/browser/BrowserRecordingSave';
+import { useThemeMode } from '../context/theme-provider';
 import { useTranslation } from 'react-i18next';
 
 interface RecordingPageProps {
@@ -27,6 +28,7 @@ export interface PairForEdit {
 }
 
 export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
+  const { darkMode } = useThemeMode();
   const { t } = useTranslation();
   const [isLoaded, setIsLoaded] = React.useState(false);
   const [hasScrollbar, setHasScrollbar] = React.useState(false);
@@ -34,6 +36,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
     pair: null,
     index: 0,
   });
+
   const [showOutputData, setShowOutputData] = useState(false);
 
   const browserContentRef = React.useRef(null);
@@ -57,15 +60,20 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => {
   useEffect(() => changeBrowserDimensions(), [isLoaded])
 
   useEffect(() => {
-    document.body.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 1) 0%, rgba(232, 191, 222, 1) 100%, rgba(255, 255, 255, 1) 100%)';
-    document.body.style.filter = 'progid:DXImageTransform.Microsoft.gradient(startColorstr="#ffffff",endColorstr="#ffffff",GradientType=1);'
+    if (darkMode) {
+
+      document.body.style.background = 'rgba(18,18,18,1)';
+
+    } else {
+      document.body.style.background = 'radial-gradient(circle, rgba(255, 255, 255, 1) 0%, rgba(232, 191, 222, 1) 100%, rgba(255, 255, 255, 1) 100%)';
+      document.body.style.filter = 'progid:DXImageTransform.Microsoft.gradient(startColorstr="#ffffff",endColorstr="#ffffff",GradientType=1);'
+    }
 
     return () => {
-      // Cleanup the background when leaving the page
       document.body.style.background = '';
       document.body.style.filter = '';
     };
-  }, []);
+  }, [darkMode]);
 
   useEffect(() => {
     let isCancelled = false;
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx
index b1d2428f..d9c698a2 100644
--- a/src/pages/Register.tsx
+++ b/src/pages/Register.tsx
@@ -5,11 +5,11 @@ import { AuthContext } from "../context/auth";
 import { Box, Typography, TextField, Button, CircularProgress } from "@mui/material";
 import { useGlobalInfoStore } from "../context/globalInfo";
 import { apiUrl } from "../apiConfig";
+import { useThemeMode } from "../context/theme-provider";
 import { useTranslation } from 'react-i18next';
 import i18n from '../i18n';
 
 
-
 const Register = () => {
   const {t} = useTranslation();
   const [form, setForm] = useState({
@@ -22,6 +22,7 @@ const Register = () => {
 
   const { state, dispatch } = useContext(AuthContext);
   const { user } = state;
+  const { darkMode } = useThemeMode();
 
   const navigate = useNavigate();
 
@@ -40,18 +41,14 @@ const Register = () => {
     e.preventDefault();
     setLoading(true);
     try {
-      const { data } = await axios.post(`${apiUrl}/auth/register`, {
-        email,
-        password,
-      });
+      const { data } = await axios.post(`${apiUrl}/auth/register`, { email, password });
+      console.log(data);
       dispatch({ type: "LOGIN", payload: data });
       notify("success", t('register.welcome_notification'));
       window.localStorage.setItem("user", JSON.stringify(data));
       navigate("/");
     } catch (error:any) {
-
       notify("error", error.response.data || t('register.error_notification'));
-
       setLoading(false);
     }
   };
@@ -65,25 +62,38 @@ const Register = () => {
         maxHeight: "100vh",
         mt: 6,
         padding: 4,
+        backgroundColor: darkMode ? "#121212" : "#ffffff",
+       
       }}
     >
       
-        logo
+        logo
         
           {t('register.title')}
         
@@ -113,7 +123,10 @@ const Register = () => {
           fullWidth
           variant="contained"
           color="primary"
-          sx={{ mt: 2, mb: 2 }}
+          sx={{
+            mt: 2,
+            mb: 2,
+          }}
           disabled={loading || !email || !password}
         >
           {loading ? (
@@ -125,10 +138,9 @@ const Register = () => {
             t('register.button')
           )}
         
-        
+        
           {t('register.register_prompt')}{" "}
-          
-            
+           
             {t('register.login_link')}