Merge branch 'develop' into internationalization2
This commit is contained in:
@@ -16,7 +16,7 @@ COPY vite.config.js ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Expose the frontend port
|
||||
EXPOSE 5173
|
||||
EXPOSE ${FRONTEND_PORT:-5173}
|
||||
|
||||
# Start the frontend using the client script
|
||||
CMD ["npm", "run", "client", "--", "--host"]
|
||||
11
README.md
11
README.md
@@ -29,12 +29,17 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
|
||||
|
||||
<img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" />
|
||||
|
||||
> Note: Maxun is in its early stages of development and currently does not support self-hosting. However, you can run Maxun locally. Self-hosting capabilities are planned for a future release and will be available soon.
|
||||
# Installation
|
||||
1. Create a root folder for your project (e.g. 'maxun')
|
||||
2. Create a file named `.env` in the root folder of the project
|
||||
3. Example env file can be viewed [here](https://github.com/getmaxun/maxun/blob/master/ENVEXAMPLE). Copy all content of example env to your `.env` file.
|
||||
4. Choose your installation method below
|
||||
|
||||
# Local Installation
|
||||
### Docker Compose
|
||||
1. Copy paste the [docker-compose.yml file](https://github.com/getmaxun/maxun/blob/master/docker-compose.yml) into your root folder
|
||||
2. Ensure you have setup the `.env` file in that same folder
|
||||
3. Run the command below from a terminal
|
||||
```
|
||||
git clone https://github.com/getmaxun/maxun
|
||||
docker-compose up -d
|
||||
```
|
||||
You can access the frontend at http://localhost:5173/ and backend at http://localhost:8080/
|
||||
|
||||
@@ -43,7 +43,7 @@ services:
|
||||
#build:
|
||||
#context: .
|
||||
#dockerfile: server/Dockerfile
|
||||
image: getmaxun/maxun-backend:v0.0.5
|
||||
image: getmaxun/maxun-backend:v0.0.7
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}"
|
||||
env_file: .env
|
||||
@@ -64,28 +64,23 @@ services:
|
||||
- redis
|
||||
- minio
|
||||
volumes:
|
||||
- ./server:/app/server # Mount server source code for hot reloading
|
||||
- ./maxun-core:/app/maxun-core # Mount maxun-core for any shared code updates
|
||||
- /var/run/dbus:/var/run/dbus
|
||||
|
||||
frontend:
|
||||
#build:
|
||||
#context: .
|
||||
#dockerfile: Dockerfile
|
||||
image: getmaxun/maxun-frontend:v0.0.2
|
||||
image: getmaxun/maxun-frontend:v0.0.3
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-5173}:${FRONTEND_PORT:-5173}"
|
||||
env_file: .env
|
||||
environment:
|
||||
PUBLIC_URL: ${PUBLIC_URL}
|
||||
BACKEND_URL: ${BACKEND_URL}
|
||||
volumes:
|
||||
- ./:/app # Mount entire frontend app directory for hot reloading
|
||||
- /app/node_modules # Anonymous volume to prevent overwriting node_modules
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
redis_data:
|
||||
redis_data:
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "maxun-core",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"description": "Core package for Maxun, responsible for data extraction",
|
||||
"main": "build/index.js",
|
||||
"typings": "build/index.d.ts",
|
||||
|
||||
@@ -265,41 +265,72 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
|
||||
const scrapedData = [];
|
||||
|
||||
while (scrapedData.length < limit) {
|
||||
// Get all parent elements matching the listSelector
|
||||
const parentElements = Array.from(document.querySelectorAll(listSelector));
|
||||
let parentElements = Array.from(document.querySelectorAll(listSelector));
|
||||
|
||||
// If we only got one element or none, try a more generic approach
|
||||
if (limit > 1 && parentElements.length <= 1) {
|
||||
const [containerSelector, _] = listSelector.split('>').map(s => s.trim());
|
||||
const container = document.querySelector(containerSelector);
|
||||
|
||||
if (container) {
|
||||
const allChildren = Array.from(container.children);
|
||||
|
||||
const firstMatch = document.querySelector(listSelector);
|
||||
if (firstMatch) {
|
||||
// Get classes from the first matching element
|
||||
const firstMatchClasses = Array.from(firstMatch.classList);
|
||||
|
||||
// Find similar elements by matching most of their classes
|
||||
parentElements = allChildren.filter(element => {
|
||||
const elementClasses = Array.from(element.classList);
|
||||
|
||||
// Iterate through each parent element
|
||||
for (const parent of parentElements) {
|
||||
if (scrapedData.length >= limit) break;
|
||||
const record = {};
|
||||
|
||||
// For each field, select the corresponding element within the parent
|
||||
for (const [label, { selector, attribute }] of Object.entries(fields)) {
|
||||
const fieldElement = parent.querySelector(selector);
|
||||
|
||||
if (fieldElement) {
|
||||
if (attribute === 'innerText') {
|
||||
record[label] = fieldElement.innerText.trim();
|
||||
} else if (attribute === 'innerHTML') {
|
||||
record[label] = fieldElement.innerHTML.trim();
|
||||
} else if (attribute === 'src') {
|
||||
// Handle relative 'src' URLs
|
||||
const src = fieldElement.getAttribute('src');
|
||||
record[label] = src ? new URL(src, window.location.origin).href : null;
|
||||
} else if (attribute === 'href') {
|
||||
// Handle relative 'href' URLs
|
||||
const href = fieldElement.getAttribute('href');
|
||||
record[label] = href ? new URL(href, window.location.origin).href : null;
|
||||
} else {
|
||||
record[label] = fieldElement.getAttribute(attribute);
|
||||
// Element should share at least 70% of classes with the first match
|
||||
const commonClasses = firstMatchClasses.filter(cls =>
|
||||
elementClasses.includes(cls));
|
||||
return commonClasses.length >= Math.floor(firstMatchClasses.length * 0.7);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scrapedData.push(record);
|
||||
}
|
||||
|
||||
// Iterate through each parent element
|
||||
for (const parent of parentElements) {
|
||||
if (scrapedData.length >= limit) break;
|
||||
const record = {};
|
||||
|
||||
// For each field, select the corresponding element within the parent
|
||||
for (const [label, { selector, attribute }] of Object.entries(fields)) {
|
||||
const fieldElement = parent.querySelector(selector);
|
||||
|
||||
if (fieldElement) {
|
||||
if (attribute === 'innerText') {
|
||||
record[label] = fieldElement.innerText.trim();
|
||||
} else if (attribute === 'innerHTML') {
|
||||
record[label] = fieldElement.innerHTML.trim();
|
||||
} else if (attribute === 'src') {
|
||||
// Handle relative 'src' URLs
|
||||
const src = fieldElement.getAttribute('src');
|
||||
record[label] = src ? new URL(src, window.location.origin).href : null;
|
||||
} else if (attribute === 'href') {
|
||||
// Handle relative 'href' URLs
|
||||
const href = fieldElement.getAttribute('href');
|
||||
record[label] = href ? new URL(href, window.location.origin).href : null;
|
||||
} else {
|
||||
record[label] = fieldElement.getAttribute(attribute);
|
||||
}
|
||||
}
|
||||
}
|
||||
scrapedData.push(record);
|
||||
}
|
||||
|
||||
// If we've processed all available elements and still haven't reached the limit,
|
||||
// break to avoid infinite loop
|
||||
if (parentElements.length === 0 || scrapedData.length >= parentElements.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return scrapedData
|
||||
};
|
||||
return scrapedData;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,7 +102,7 @@ export default class Interpreter extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch).then(blocker => {
|
||||
PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']).then(blocker => {
|
||||
this.blocker = blocker;
|
||||
}).catch(err => {
|
||||
this.log(`Failed to initialize ad-blocker:`, Level.ERROR);
|
||||
@@ -192,8 +192,8 @@ export default class Interpreter extends EventEmitter {
|
||||
// const actionable = async (selector: string): Promise<boolean> => {
|
||||
// try {
|
||||
// const proms = [
|
||||
// page.isEnabled(selector, { timeout: 5000 }),
|
||||
// page.isVisible(selector, { timeout: 5000 }),
|
||||
// page.isEnabled(selector, { timeout: 10000 }),
|
||||
// page.isVisible(selector, { timeout: 10000 }),
|
||||
// ];
|
||||
|
||||
// return await Promise.all(proms).then((bools) => bools.every((x) => x));
|
||||
@@ -214,6 +214,17 @@ export default class Interpreter extends EventEmitter {
|
||||
// return [];
|
||||
// }),
|
||||
// ).then((x) => x.flat());
|
||||
|
||||
const presentSelectors: SelectorArray = await Promise.all(
|
||||
selectors.map(async (selector) => {
|
||||
try {
|
||||
await page.waitForSelector(selector, { state: 'attached' });
|
||||
return [selector];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
).then((x) => x.flat());
|
||||
|
||||
const action = workflowCopy[workflowCopy.length - 1];
|
||||
|
||||
@@ -233,7 +244,7 @@ export default class Interpreter extends EventEmitter {
|
||||
...p,
|
||||
[cookie.name]: cookie.value,
|
||||
}), {}),
|
||||
selectors,
|
||||
selectors: presentSelectors,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -506,7 +517,11 @@ export default class Interpreter extends EventEmitter {
|
||||
try {
|
||||
await executeAction(invokee, methodName, step.args);
|
||||
} catch (error) {
|
||||
await executeAction(invokee, methodName, [step.args[0], { force: true }]);
|
||||
try{
|
||||
await executeAction(invokee, methodName, [step.args[0], { force: true }]);
|
||||
} catch (error) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await executeAction(invokee, methodName, step.args);
|
||||
@@ -767,6 +782,8 @@ export default class Interpreter extends EventEmitter {
|
||||
public async run(page: Page, params?: ParamType): Promise<void> {
|
||||
this.log('Starting the workflow.', Level.LOG);
|
||||
const context = page.context();
|
||||
|
||||
page.setDefaultNavigationTimeout(100000);
|
||||
|
||||
// Check proxy settings from context options
|
||||
const contextOptions = (context as any)._options;
|
||||
|
||||
@@ -3,36 +3,36 @@
|
||||
*/
|
||||
export default class Concurrency {
|
||||
/**
|
||||
* Maximum number of workers running in parallel. If set to `null`, there is no limit.
|
||||
*/
|
||||
* Maximum number of workers running in parallel. If set to `null`, there is no limit.
|
||||
*/
|
||||
maxConcurrency: number = 1;
|
||||
|
||||
/**
|
||||
* Number of currently active workers.
|
||||
*/
|
||||
* Number of currently active workers.
|
||||
*/
|
||||
activeWorkers: number = 0;
|
||||
|
||||
/**
|
||||
* Queue of jobs waiting to be completed.
|
||||
*/
|
||||
* Queue of jobs waiting to be completed.
|
||||
*/
|
||||
private jobQueue: Function[] = [];
|
||||
|
||||
/**
|
||||
* "Resolve" callbacks of the waitForCompletion() promises.
|
||||
*/
|
||||
* "Resolve" callbacks of the waitForCompletion() promises.
|
||||
*/
|
||||
private waiting: Function[] = [];
|
||||
|
||||
/**
|
||||
* Constructs a new instance of concurrency manager.
|
||||
* @param {number} maxConcurrency Maximum number of workers running in parallel.
|
||||
*/
|
||||
* Constructs a new instance of concurrency manager.
|
||||
* @param {number} maxConcurrency Maximum number of workers running in parallel.
|
||||
*/
|
||||
constructor(maxConcurrency: number) {
|
||||
this.maxConcurrency = maxConcurrency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a waiting job out of the queue and runs it.
|
||||
*/
|
||||
* Takes a waiting job out of the queue and runs it.
|
||||
*/
|
||||
private runNextJob(): void {
|
||||
const job = this.jobQueue.pop();
|
||||
|
||||
@@ -53,12 +53,12 @@ export default class Concurrency {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass a job (a time-demanding async function) to the concurrency manager. \
|
||||
* The time of the job's execution depends on the concurrency manager itself
|
||||
* (given a generous enough `maxConcurrency` value, it might be immediate,
|
||||
* but this is not guaranteed).
|
||||
* @param worker Async function to be executed (job to be processed).
|
||||
*/
|
||||
* Pass a job (a time-demanding async function) to the concurrency manager. \
|
||||
* The time of the job's execution depends on the concurrency manager itself
|
||||
* (given a generous enough `maxConcurrency` value, it might be immediate,
|
||||
* but this is not guaranteed).
|
||||
* @param worker Async function to be executed (job to be processed).
|
||||
*/
|
||||
addJob(job: () => Promise<any>): void {
|
||||
// console.debug("Adding a worker!");
|
||||
this.jobQueue.push(job);
|
||||
@@ -72,11 +72,11 @@ export default class Concurrency {
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until there is no running nor waiting job. \
|
||||
* If the concurrency manager is idle at the time of calling this function,
|
||||
* it waits until at least one job is completed (can be "presubscribed").
|
||||
* @returns Promise, resolved after there is no running/waiting worker.
|
||||
*/
|
||||
* Waits until there is no running nor waiting job. \
|
||||
* If the concurrency manager is idle at the time of calling this function,
|
||||
* it waits until at least one job is completed (can be "presubscribed").
|
||||
* @returns Promise, resolved after there is no running/waiting worker.
|
||||
*/
|
||||
waitForCompletion(): Promise<void> {
|
||||
return new Promise((res) => {
|
||||
this.waiting.push(res);
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"jwt-decode": "^4.0.0",
|
||||
"loglevel": "^1.8.0",
|
||||
"loglevel-plugin-remote": "^0.6.8",
|
||||
"maxun-core": "^0.0.5",
|
||||
"maxun-core": "^0.0.6",
|
||||
"minio": "^8.0.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"node-cron": "^3.0.3",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
|
||||
FROM mcr.microsoft.com/playwright:v1.46.0-noble
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
@@ -50,7 +50,7 @@ RUN apt-get update && apt-get install -y \
|
||||
# RUN chmod +x ./start.sh
|
||||
|
||||
# Expose the backend port
|
||||
EXPOSE 8080
|
||||
EXPOSE ${BACKEND_PORT:-8080}
|
||||
|
||||
# Start the backend using the start script
|
||||
CMD ["npm", "run", "server"]
|
||||
@@ -104,7 +104,7 @@ export class RemoteBrowser {
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a URL change is significant enough to emit
|
||||
@@ -130,11 +130,11 @@ export class RemoteBrowser {
|
||||
});
|
||||
|
||||
// Handle page load events with retry mechanism
|
||||
page.on('load', async () => {
|
||||
page.on('load', async () => {
|
||||
const injectScript = async (): Promise<boolean> => {
|
||||
try {
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
||||
|
||||
|
||||
await page.evaluate(getInjectableScript());
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
@@ -148,6 +148,19 @@ export class RemoteBrowser {
|
||||
});
|
||||
}
|
||||
|
||||
private getUserAgent() {
|
||||
const userAgents = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.140 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:117.0) Gecko/20100101 Firefox/117.0',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.1938.81 Safari/537.36 Edg/116.0.1938.81',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.96 Safari/537.36 OPR/101.0.4843.25',
|
||||
'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)];
|
||||
}
|
||||
|
||||
/**
|
||||
* An asynchronous constructor for asynchronously initialized properties.
|
||||
* Must be called right after creating an instance of RemoteBrowser class.
|
||||
@@ -155,37 +168,17 @@ export class RemoteBrowser {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public initialize = async (userId: string): Promise<void> => {
|
||||
// const launchOptions = {
|
||||
// headless: true,
|
||||
// proxy: options.launchOptions?.proxy,
|
||||
// chromiumSandbox: false,
|
||||
// args: [
|
||||
// '--no-sandbox',
|
||||
// '--disable-setuid-sandbox',
|
||||
// '--headless=new',
|
||||
// '--disable-gpu',
|
||||
// '--disable-dev-shm-usage',
|
||||
// '--disable-software-rasterizer',
|
||||
// '--in-process-gpu',
|
||||
// '--disable-infobars',
|
||||
// '--single-process',
|
||||
// '--no-zygote',
|
||||
// '--disable-notifications',
|
||||
// '--disable-extensions',
|
||||
// '--disable-background-timer-throttling',
|
||||
// ...(options.launchOptions?.args || [])
|
||||
// ],
|
||||
// env: {
|
||||
// ...process.env,
|
||||
// CHROMIUM_FLAGS: '--disable-gpu --no-sandbox --headless=new'
|
||||
// }
|
||||
// };
|
||||
// console.log('Launch options before:', options.launchOptions);
|
||||
// this.browser = <Browser>(await options.browser.launch(launchOptions));
|
||||
|
||||
// console.log('Launch options after:', options.launchOptions)
|
||||
this.browser = <Browser>(await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-web-security",
|
||||
"--disable-features=IsolateOrigins,site-per-process",
|
||||
"--disable-site-isolation-trials",
|
||||
"--disable-extensions",
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
],
|
||||
}));
|
||||
const proxyConfig = await getDecryptedProxyConfig(userId);
|
||||
let proxyOptions: { server: string, username?: string, password?: string } = { server: '' };
|
||||
@@ -201,7 +194,7 @@ export class RemoteBrowser {
|
||||
const contextOptions: any = {
|
||||
viewport: { height: 400, width: 900 },
|
||||
// recordVideo: { dir: 'videos/' }
|
||||
// Force reduced motion to prevent animation issues
|
||||
// Force reduced motion to prevent animation issues
|
||||
reducedMotion: 'reduce',
|
||||
// Force JavaScript to be enabled
|
||||
javaScriptEnabled: true,
|
||||
@@ -210,7 +203,8 @@ export class RemoteBrowser {
|
||||
// Disable hardware acceleration
|
||||
forcedColors: 'none',
|
||||
isMobile: false,
|
||||
hasTouch: false
|
||||
hasTouch: false,
|
||||
userAgent: this.getUserAgent(),
|
||||
};
|
||||
|
||||
if (proxyOptions.server) {
|
||||
@@ -220,19 +214,38 @@ export class RemoteBrowser {
|
||||
password: proxyOptions.password ? proxyOptions.password : undefined,
|
||||
};
|
||||
}
|
||||
const browserUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.5481.38 Safari/537.36";
|
||||
|
||||
|
||||
contextOptions.userAgent = browserUserAgent;
|
||||
this.context = await this.browser.newContext(contextOptions);
|
||||
await this.context.addInitScript(
|
||||
`const defaultGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
defaultGetter.apply(navigator);
|
||||
defaultGetter.toString();
|
||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||
set: undefined,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: new Proxy(defaultGetter, {
|
||||
apply: (target, thisArg, args) => {
|
||||
Reflect.apply(target, thisArg, args);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const patchedGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
patchedGetter.apply(navigator);
|
||||
patchedGetter.toString();`
|
||||
);
|
||||
this.currentPage = await this.context.newPage();
|
||||
|
||||
await this.setupPageEventListeners(this.currentPage);
|
||||
|
||||
// await this.currentPage.setExtraHTTPHeaders({
|
||||
// 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
|
||||
// });
|
||||
const blocker = await PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch);
|
||||
const blocker = await PlaywrightBlocker.fromLists(fetch, ['https://easylist.to/easylist/easylist.txt']);
|
||||
await blocker.enableBlockingInPage(this.currentPage);
|
||||
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
||||
await blocker.disableBlockingInPage(this.currentPage);
|
||||
@@ -456,7 +469,7 @@ export class RemoteBrowser {
|
||||
this.currentPage = newPage;
|
||||
if (this.currentPage) {
|
||||
await this.setupPageEventListeners(this.currentPage);
|
||||
|
||||
|
||||
this.client = await this.currentPage.context().newCDPSession(this.currentPage);
|
||||
await this.subscribeToScreencast();
|
||||
} else {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
import logger from "../logger";
|
||||
import { Coordinates, ScrollDeltas, KeyboardInput } from '../types';
|
||||
import { Coordinates, ScrollDeltas, KeyboardInput, DatePickerEventData } from '../types';
|
||||
import { browserPool } from "../server";
|
||||
import { WorkflowGenerator } from "../workflow-management/classes/Generator";
|
||||
import { Page } from "playwright";
|
||||
@@ -223,6 +223,53 @@ const handleKeydown = async (generator: WorkflowGenerator, page: Page, { key, co
|
||||
logger.log('debug', `Key ${key} pressed`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the date selection event.
|
||||
* @param generator - the workflow generator {@link Generator}
|
||||
* @param page - the active page of the remote browser
|
||||
* @param data - the data of the date selection event {@link DatePickerEventData}
|
||||
* @category BrowserManagement
|
||||
*/
|
||||
const handleDateSelection = async (generator: WorkflowGenerator, page: Page, data: DatePickerEventData) => {
|
||||
await generator.onDateSelection(page, data);
|
||||
logger.log('debug', `Date ${data.value} selected`);
|
||||
}
|
||||
|
||||
const onDateSelection = async (data: DatePickerEventData) => {
|
||||
logger.log('debug', 'Handling date selection event emitted from client');
|
||||
await handleWrapper(handleDateSelection, data);
|
||||
}
|
||||
|
||||
const handleDropdownSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
||||
await generator.onDropdownSelection(page, data);
|
||||
logger.log('debug', `Dropdown value ${data.value} selected`);
|
||||
}
|
||||
|
||||
const onDropdownSelection = async (data: { selector: string, value: string }) => {
|
||||
logger.log('debug', 'Handling dropdown selection event emitted from client');
|
||||
await handleWrapper(handleDropdownSelection, data);
|
||||
}
|
||||
|
||||
const handleTimeSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
||||
await generator.onTimeSelection(page, data);
|
||||
logger.log('debug', `Time value ${data.value} selected`);
|
||||
}
|
||||
|
||||
const onTimeSelection = async (data: { selector: string, value: string }) => {
|
||||
logger.log('debug', 'Handling time selection event emitted from client');
|
||||
await handleWrapper(handleTimeSelection, data);
|
||||
}
|
||||
|
||||
const handleDateTimeLocalSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
||||
await generator.onDateTimeLocalSelection(page, data);
|
||||
logger.log('debug', `DateTime Local value ${data.value} selected`);
|
||||
}
|
||||
|
||||
const onDateTimeLocalSelection = async (data: { selector: string, value: string }) => {
|
||||
logger.log('debug', 'Handling datetime-local selection event emitted from client');
|
||||
await handleWrapper(handleDateTimeLocalSelection, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper function for handling the keyup event.
|
||||
* @param keyboardInput - the keyboard input of the keyup event
|
||||
@@ -378,6 +425,10 @@ const registerInputHandlers = (socket: Socket) => {
|
||||
socket.on("input:refresh", onRefresh);
|
||||
socket.on("input:back", onGoBack);
|
||||
socket.on("input:forward", onGoForward);
|
||||
socket.on("input:date", onDateSelection);
|
||||
socket.on("input:dropdown", onDropdownSelection);
|
||||
socket.on("input:time", onTimeSelection);
|
||||
socket.on("input:datetime-local", onDateTimeLocalSelection);
|
||||
socket.on("action", onGenerateAction);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import swaggerJSDoc from 'swagger-jsdoc';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Dynamically determine API file paths
|
||||
const jsFiles = [path.join(__dirname, '../api/*.js')]
|
||||
const tsFiles = [path.join(__dirname, '../api/*.ts')]
|
||||
|
||||
let apis = fs.existsSync(jsFiles[0]) ? jsFiles : tsFiles;
|
||||
|
||||
if (!apis) {
|
||||
throw new Error('No valid API files found! Ensure either .js or .ts files exist in the ../api/ directory.');
|
||||
}
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Maxun API Documentation',
|
||||
title: 'Website to API',
|
||||
version: '1.0.0',
|
||||
description: 'API documentation for Maxun (https://github.com/getmaxun/maxun)',
|
||||
description:
|
||||
'Maxun lets you get the data your robot extracted and run robots via API. All you need to do is input the Maxun API key by clicking Authorize below.',
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
@@ -15,7 +27,8 @@ const options = {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'x-api-key',
|
||||
description: 'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.',
|
||||
description:
|
||||
'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -25,7 +38,7 @@ const options = {
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: process.env.NODE_ENV === 'production' ? [path.join(__dirname, '../api/*.js')] : [path.join(__dirname, '../api/*.ts')]
|
||||
apis,
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJSDoc(options);
|
||||
|
||||
@@ -20,6 +20,16 @@ export interface Coordinates {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* interface to handle date picker events.
|
||||
* @category Types
|
||||
*/
|
||||
export interface DatePickerEventData {
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the deltas of a wheel/scroll event.
|
||||
* @category Types
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Action, ActionType, Coordinates, TagName } from "../../types";
|
||||
import { Action, ActionType, Coordinates, TagName, DatePickerEventData } from "../../types";
|
||||
import { WhereWhatPair, WorkflowFile } from 'maxun-core';
|
||||
import logger from "../../logger";
|
||||
import { Socket } from "socket.io";
|
||||
@@ -140,19 +140,22 @@ export class WorkflowGenerator {
|
||||
socket.on('decision', async ({ pair, actionType, decision }) => {
|
||||
const id = browserPool.getActiveBrowserId();
|
||||
if (id) {
|
||||
const activeBrowser = browserPool.getRemoteBrowser(id);
|
||||
const currentPage = activeBrowser?.getCurrentPage();
|
||||
if (decision) {
|
||||
// const activeBrowser = browserPool.getRemoteBrowser(id);
|
||||
// const currentPage = activeBrowser?.getCurrentPage();
|
||||
if (!decision) {
|
||||
switch (actionType) {
|
||||
case 'customAction':
|
||||
pair.where.selectors = [this.generatedData.lastUsedSelector];
|
||||
// pair.where.selectors = [this.generatedData.lastUsedSelector];
|
||||
pair.where.selectors = pair.where.selectors.filter(
|
||||
(selector: string) => selector !== this.generatedData.lastUsedSelector
|
||||
);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
if (currentPage) {
|
||||
await this.addPairToWorkflowAndNotifyClient(pair, currentPage);
|
||||
}
|
||||
// if (currentPage) {
|
||||
// await this.addPairToWorkflowAndNotifyClient(pair, currentPage);
|
||||
// }
|
||||
}
|
||||
})
|
||||
socket.on('updatePair', (data) => {
|
||||
@@ -252,6 +255,85 @@ export class WorkflowGenerator {
|
||||
logger.log('info', `Workflow emitted`);
|
||||
};
|
||||
|
||||
public onDateSelection = async (page: Page, data: DatePickerEventData) => {
|
||||
const { selector, value } = data;
|
||||
|
||||
try {
|
||||
await page.fill(selector, value);
|
||||
} catch (error) {
|
||||
console.error("Failed to fill date value:", error);
|
||||
}
|
||||
|
||||
const pair: WhereWhatPair = {
|
||||
where: { url: this.getBestUrl(page.url()) },
|
||||
what: [{
|
||||
action: 'fill',
|
||||
args: [selector, value],
|
||||
}],
|
||||
};
|
||||
|
||||
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||
};
|
||||
|
||||
public onDropdownSelection = async (page: Page, data: { selector: string, value: string }) => {
|
||||
const { selector, value } = data;
|
||||
|
||||
try {
|
||||
await page.selectOption(selector, value);
|
||||
} catch (error) {
|
||||
console.error("Failed to fill date value:", error);
|
||||
}
|
||||
|
||||
const pair: WhereWhatPair = {
|
||||
where: { url: this.getBestUrl(page.url()) },
|
||||
what: [{
|
||||
action: 'selectOption',
|
||||
args: [selector, value],
|
||||
}],
|
||||
};
|
||||
|
||||
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||
};
|
||||
|
||||
public onTimeSelection = async (page: Page, data: { selector: string, value: string }) => {
|
||||
const { selector, value } = data;
|
||||
|
||||
try {
|
||||
await page.fill(selector, value);
|
||||
} catch (error) {
|
||||
console.error("Failed to set time value:", error);
|
||||
}
|
||||
|
||||
const pair: WhereWhatPair = {
|
||||
where: { url: this.getBestUrl(page.url()) },
|
||||
what: [{
|
||||
action: 'fill',
|
||||
args: [selector, value],
|
||||
}],
|
||||
};
|
||||
|
||||
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||
};
|
||||
|
||||
public onDateTimeLocalSelection = async (page: Page, data: { selector: string, value: string }) => {
|
||||
const { selector, value } = data;
|
||||
|
||||
try {
|
||||
await page.fill(selector, value);
|
||||
} catch (error) {
|
||||
console.error("Failed to fill datetime-local value:", error);
|
||||
}
|
||||
|
||||
const pair: WhereWhatPair = {
|
||||
where: { url: this.getBestUrl(page.url()) },
|
||||
what: [{
|
||||
action: 'fill',
|
||||
args: [selector, value],
|
||||
}],
|
||||
};
|
||||
|
||||
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a pair for the click event.
|
||||
@@ -263,6 +345,81 @@ export class WorkflowGenerator {
|
||||
let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) };
|
||||
const selector = await this.generateSelector(page, coordinates, ActionType.Click);
|
||||
logger.log('debug', `Element's selector: ${selector}`);
|
||||
|
||||
const elementInfo = await getElementInformation(page, coordinates, '', false);
|
||||
console.log("Element info: ", elementInfo);
|
||||
|
||||
// Check if clicked element is a select dropdown
|
||||
const isDropdown = elementInfo?.tagName === 'SELECT';
|
||||
|
||||
if (isDropdown && elementInfo.innerHTML) {
|
||||
// Parse options from innerHTML
|
||||
const options = elementInfo.innerHTML
|
||||
.split('<option')
|
||||
.slice(1) // Remove first empty element
|
||||
.map(optionHtml => {
|
||||
const valueMatch = optionHtml.match(/value="([^"]*)"/);
|
||||
const disabledMatch = optionHtml.includes('disabled="disabled"');
|
||||
const selectedMatch = optionHtml.includes('selected="selected"');
|
||||
|
||||
// Extract text content between > and </option>
|
||||
const textMatch = optionHtml.match(/>([^<]*)</);
|
||||
const text = textMatch
|
||||
? textMatch[1]
|
||||
.replace(/\n/g, '') // Remove all newlines
|
||||
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
return {
|
||||
value: valueMatch ? valueMatch[1] : '',
|
||||
text,
|
||||
disabled: disabledMatch,
|
||||
selected: selectedMatch
|
||||
};
|
||||
});
|
||||
|
||||
// Notify client to show dropdown overlay
|
||||
this.socket.emit('showDropdown', {
|
||||
coordinates,
|
||||
selector,
|
||||
options
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if clicked element is a date input
|
||||
const isDateInput = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'date';
|
||||
|
||||
if (isDateInput) {
|
||||
// Notify client to show datepicker overlay
|
||||
this.socket.emit('showDatePicker', {
|
||||
coordinates,
|
||||
selector
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isTimeInput = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'time';
|
||||
|
||||
if (isTimeInput) {
|
||||
this.socket.emit('showTimePicker', {
|
||||
coordinates,
|
||||
selector
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isDateTimeLocal = elementInfo?.tagName === 'INPUT' && elementInfo?.attributes?.type === 'datetime-local';
|
||||
|
||||
if (isDateTimeLocal) {
|
||||
this.socket.emit('showDateTimePicker', {
|
||||
coordinates,
|
||||
selector
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
//const element = await getElementMouseIsOver(page, coordinates);
|
||||
//logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`);
|
||||
if (selector) {
|
||||
@@ -360,6 +517,8 @@ export class WorkflowGenerator {
|
||||
}],
|
||||
}
|
||||
|
||||
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||
|
||||
if (this.generatedData.lastUsedSelector) {
|
||||
const elementInfo = await this.getLastUsedSelectorInfo(page, this.generatedData.lastUsedSelector);
|
||||
|
||||
@@ -372,9 +531,7 @@ export class WorkflowGenerator {
|
||||
innerText: elementInfo.innerText,
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -541,10 +698,9 @@ export class WorkflowGenerator {
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
private generateSelector = async (page: Page, coordinates: Coordinates, action: ActionType) => {
|
||||
const elementInfo = await getElementInformation(page, coordinates);
|
||||
|
||||
const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList);
|
||||
const selectorBasedOnCustomAction = (this.getList === true)
|
||||
? await getNonUniqueSelectors(page, coordinates)
|
||||
? await getNonUniqueSelectors(page, coordinates, this.listSelector)
|
||||
: await getSelectors(page, coordinates);
|
||||
|
||||
const bestSelector = getBestSelectorForAction(
|
||||
@@ -570,16 +726,14 @@ export class WorkflowGenerator {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public generateDataForHighlighter = async (page: Page, coordinates: Coordinates) => {
|
||||
const rect = await getRect(page, coordinates);
|
||||
const rect = await getRect(page, coordinates, this.listSelector, this.getList);
|
||||
const displaySelector = await this.generateSelector(page, coordinates, ActionType.Click);
|
||||
const elementInfo = await getElementInformation(page, coordinates);
|
||||
const elementInfo = await getElementInformation(page, coordinates, this.listSelector, this.getList);
|
||||
if (rect) {
|
||||
if (this.getList === true) {
|
||||
if (this.listSelector !== '') {
|
||||
const childSelectors = await getChildSelectors(page, this.listSelector || '');
|
||||
this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo, childSelectors })
|
||||
console.log(`Child Selectors: ${childSelectors}`)
|
||||
console.log(`Parent Selector: ${this.listSelector}`)
|
||||
} else {
|
||||
this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo });
|
||||
}
|
||||
|
||||
@@ -1,16 +1,161 @@
|
||||
import { Page } from "playwright";
|
||||
import { Action, ActionType, Coordinates, TagName } from "../types";
|
||||
import { Coordinates } from "../types";
|
||||
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
||||
import logger from "../logger";
|
||||
import { getBestSelectorForAction } from "./utils";
|
||||
|
||||
/*TODO:
|
||||
1. Handle TS errors (here we definetly know better)
|
||||
2. Add pending function descriptions + thought process (esp. selector generation)
|
||||
*/
|
||||
|
||||
type Workflow = WorkflowFile["workflow"];
|
||||
|
||||
/**
|
||||
* Checks the basic info about an element and returns a {@link BaseActionInfo} object.
|
||||
* If the element is not found, returns undefined.
|
||||
* @param page The page instance.
|
||||
* @param coordinates Coordinates of an element.
|
||||
* @category WorkflowManagement-Selectors
|
||||
* @returns {Promise<BaseActionInfo|undefined>}
|
||||
*/
|
||||
export const getElementInformation = async (
|
||||
page: Page,
|
||||
coordinates: Coordinates,
|
||||
listSelector: string,
|
||||
getList: boolean
|
||||
) => {
|
||||
try {
|
||||
if (!getList || listSelector !== '') {
|
||||
const elementInfo = await page.evaluate(
|
||||
async ({ x, y }) => {
|
||||
const el = document.elementFromPoint(x, y) as HTMLElement;
|
||||
if (el) {
|
||||
const { parentElement } = el;
|
||||
const element = parentElement?.tagName === 'A' ? parentElement : el;
|
||||
let info: {
|
||||
tagName: string;
|
||||
hasOnlyText?: boolean;
|
||||
innerText?: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
attributes?: Record<string, string>;
|
||||
innerHTML?: string;
|
||||
outerHTML?: string;
|
||||
} = {
|
||||
tagName: element?.tagName ?? '',
|
||||
};
|
||||
if (element) {
|
||||
info.attributes = Array.from(element.attributes).reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.name] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
// 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 },
|
||||
);
|
||||
return elementInfo;
|
||||
} else {
|
||||
const elementInfo = await page.evaluate(
|
||||
async ({ x, y }) => {
|
||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||
if (originalEl) {
|
||||
let element = originalEl;
|
||||
|
||||
while (element.parentElement) {
|
||||
const parentRect = element.parentElement.getBoundingClientRect();
|
||||
const childRect = element.getBoundingClientRect();
|
||||
|
||||
const fullyContained =
|
||||
parentRect.left <= childRect.left &&
|
||||
parentRect.right >= childRect.right &&
|
||||
parentRect.top <= childRect.top &&
|
||||
parentRect.bottom >= childRect.bottom;
|
||||
|
||||
const significantOverlap =
|
||||
(childRect.width * childRect.height) /
|
||||
(parentRect.width * parentRect.height) > 0.5;
|
||||
|
||||
if (fullyContained && significantOverlap) {
|
||||
element = element.parentElement;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let info: {
|
||||
tagName: string;
|
||||
hasOnlyText?: boolean;
|
||||
innerText?: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
attributes?: Record<string, string>;
|
||||
innerHTML?: string;
|
||||
outerHTML?: string;
|
||||
} = {
|
||||
tagName: element?.tagName ?? '',
|
||||
};
|
||||
|
||||
if (element) {
|
||||
info.attributes = Array.from(element.attributes).reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.name] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
{ x: coordinates.x, y: coordinates.y },
|
||||
);
|
||||
return elementInfo;
|
||||
}
|
||||
} catch (error) {
|
||||
const { message, stack } = error as Error;
|
||||
console.error('Error while retrieving selector:', message);
|
||||
console.error('Stack:', stack);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a {@link Rectangle} object representing
|
||||
* the coordinates, width, height and corner points of the element.
|
||||
@@ -20,182 +165,89 @@ type Workflow = WorkflowFile["workflow"];
|
||||
* @category WorkflowManagement-Selectors
|
||||
* @returns {Promise<Rectangle|undefined|null>}
|
||||
*/
|
||||
export const getElementInformation = async (
|
||||
page: Page,
|
||||
coordinates: Coordinates
|
||||
) => {
|
||||
export const getRect = async (page: Page, coordinates: Coordinates, listSelector: string, getList: boolean) => {
|
||||
try {
|
||||
const elementInfo = await page.evaluate(
|
||||
async ({ x, y }) => {
|
||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||
if (originalEl) {
|
||||
let element = originalEl;
|
||||
|
||||
// if (originalEl.tagName === 'A') {
|
||||
// element = originalEl;
|
||||
// } else if (originalEl.parentElement?.tagName === 'A') {
|
||||
// element = originalEl.parentElement;
|
||||
// } else {
|
||||
// Generic parent finding logic based on visual containment
|
||||
const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE',
|
||||
'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME',
|
||||
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET',
|
||||
'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT',
|
||||
'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT',
|
||||
'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET',
|
||||
'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A'
|
||||
];
|
||||
while (element.parentElement) {
|
||||
const parentRect = element.parentElement.getBoundingClientRect();
|
||||
const childRect = element.getBoundingClientRect();
|
||||
if (!getList || listSelector !== '') {
|
||||
const rect = await page.evaluate(
|
||||
async ({ x, y }) => {
|
||||
const el = document.elementFromPoint(x, y) as HTMLElement;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 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;
|
||||
if (originalEl) {
|
||||
let element = originalEl;
|
||||
|
||||
if (!containerTags.includes(element.parentElement.tagName)) {
|
||||
break;
|
||||
while (element.parentElement) {
|
||||
const parentRect = element.parentElement.getBoundingClientRect();
|
||||
const childRect = element.getBoundingClientRect();
|
||||
|
||||
const fullyContained =
|
||||
parentRect.left <= childRect.left &&
|
||||
parentRect.right >= childRect.right &&
|
||||
parentRect.top <= childRect.top &&
|
||||
parentRect.bottom >= childRect.bottom;
|
||||
|
||||
const significantOverlap =
|
||||
(childRect.width * childRect.height) /
|
||||
(parentRect.width * parentRect.height) > 0.5;
|
||||
|
||||
if (fullyContained && significantOverlap) {
|
||||
element = element.parentElement;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if parent visually contains the child
|
||||
const fullyContained =
|
||||
parentRect.left <= childRect.left &&
|
||||
parentRect.right >= childRect.right &&
|
||||
parentRect.top <= childRect.top &&
|
||||
parentRect.bottom >= childRect.bottom;
|
||||
const rectangle = element?.getBoundingClientRect();
|
||||
|
||||
// Additional checks for more comprehensive containment
|
||||
const significantOverlap =
|
||||
(childRect.width * childRect.height) /
|
||||
(parentRect.width * parentRect.height) > 0.5;
|
||||
|
||||
if (fullyContained && significantOverlap) {
|
||||
element = element.parentElement;
|
||||
} else {
|
||||
break;
|
||||
// }
|
||||
} }
|
||||
|
||||
let info: {
|
||||
tagName: string;
|
||||
hasOnlyText?: boolean;
|
||||
innerText?: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
attributes?: Record<string, string>;
|
||||
innerHTML?: string;
|
||||
outerHTML?: string;
|
||||
} = {
|
||||
tagName: element?.tagName ?? '',
|
||||
};
|
||||
|
||||
if (element) {
|
||||
info.attributes = Array.from(element.attributes).reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.name] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
// Existing tag-specific logic
|
||||
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;
|
||||
},
|
||||
{ x: coordinates.x, y: coordinates.y },
|
||||
);
|
||||
return elementInfo;
|
||||
} catch (error) {
|
||||
const { message, stack } = error as Error;
|
||||
console.error('Error while retrieving selector:', message);
|
||||
console.error('Stack:', stack);
|
||||
}
|
||||
};
|
||||
|
||||
export const getRect = async (page: Page, coordinates: Coordinates) => {
|
||||
try {
|
||||
const rect = await page.evaluate(
|
||||
async ({ x, y }) => {
|
||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||
if (originalEl) {
|
||||
let element = originalEl;
|
||||
|
||||
// if (originalEl.tagName === 'A') {
|
||||
// element = originalEl;
|
||||
// } else if (originalEl.parentElement?.tagName === 'A') {
|
||||
// element = originalEl.parentElement;
|
||||
// } else {
|
||||
const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE',
|
||||
'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME',
|
||||
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET',
|
||||
'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT',
|
||||
'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT',
|
||||
'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET',
|
||||
'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A'
|
||||
];
|
||||
while (element.parentElement) {
|
||||
const parentRect = element.parentElement.getBoundingClientRect();
|
||||
const childRect = element.getBoundingClientRect();
|
||||
|
||||
if (!containerTags.includes(element.parentElement.tagName)) {
|
||||
break;
|
||||
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 fullyContained =
|
||||
parentRect.left <= childRect.left &&
|
||||
parentRect.right >= childRect.right &&
|
||||
parentRect.top <= childRect.top &&
|
||||
parentRect.bottom >= childRect.bottom;
|
||||
|
||||
const significantOverlap =
|
||||
(childRect.width * childRect.height) /
|
||||
(parentRect.width * parentRect.height) > 0.5;
|
||||
|
||||
if (fullyContained && significantOverlap) {
|
||||
element = element.parentElement;
|
||||
} else {
|
||||
break;
|
||||
// }
|
||||
}}
|
||||
|
||||
//element = element?.parentElement?.tagName === 'A' ? element?.parentElement : element;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
{ x: coordinates.x, y: coordinates.y },
|
||||
);
|
||||
return rect;
|
||||
return null;
|
||||
},
|
||||
{ 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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
@@ -809,86 +861,123 @@ interface SelectorResult {
|
||||
* @returns {Promise<Selectors|null|undefined>}
|
||||
*/
|
||||
|
||||
export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates): Promise<SelectorResult> => {
|
||||
export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates, listSelector: string): Promise<SelectorResult> => {
|
||||
try {
|
||||
const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => {
|
||||
function getNonUniqueSelector(element: HTMLElement): string {
|
||||
let selector = element.tagName.toLowerCase();
|
||||
if (!listSelector) {
|
||||
console.log(`NON UNIQUE: MODE 1`)
|
||||
const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => {
|
||||
function getNonUniqueSelector(element: HTMLElement): string {
|
||||
let selector = element.tagName.toLowerCase();
|
||||
|
||||
if (element.className) {
|
||||
const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
||||
if (classes.length > 0) {
|
||||
const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
||||
if (validClasses.length > 0) {
|
||||
selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
||||
if (element.className) {
|
||||
const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
||||
if (classes.length > 0) {
|
||||
const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
||||
if (validClasses.length > 0) {
|
||||
selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
return selector;
|
||||
}
|
||||
function getSelectorPath(element: HTMLElement | null): string {
|
||||
const path: string[] = [];
|
||||
let depth = 0;
|
||||
const maxDepth = 2;
|
||||
|
||||
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);
|
||||
path.unshift(selector);
|
||||
element = element.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
while (element && element !== document.body && depth < maxDepth) {
|
||||
const selector = getNonUniqueSelector(element);
|
||||
path.unshift(selector);
|
||||
element = element.parentElement;
|
||||
depth++;
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||
if (!originalEl) return null;
|
||||
|
||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||
if (!originalEl) return null;
|
||||
let element = originalEl;
|
||||
|
||||
let element = originalEl;
|
||||
// if (listSelector === '') {
|
||||
while (element.parentElement) {
|
||||
const parentRect = element.parentElement.getBoundingClientRect();
|
||||
const childRect = element.getBoundingClientRect();
|
||||
|
||||
const containerTags = ['DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE',
|
||||
'ADDRESS', 'BLOCKQUOTE', 'DETAILS', 'DIALOG', 'FIGURE', 'FIGCAPTION', 'MAIN', 'MARK', 'SUMMARY', 'TIME',
|
||||
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TH', 'TD', 'CAPTION', 'COLGROUP', 'COL', 'FORM', 'FIELDSET',
|
||||
'LEGEND', 'LABEL', 'INPUT', 'BUTTON', 'SELECT', 'DATALIST', 'OPTGROUP', 'OPTION', 'TEXTAREA', 'OUTPUT',
|
||||
'PROGRESS', 'METER', 'DETAILS', 'SUMMARY', 'MENU', 'MENUITEM', 'MENUITEM', 'APPLET', 'EMBED', 'OBJECT',
|
||||
'PARAM', 'VIDEO', 'AUDIO', 'SOURCE', 'TRACK', 'CANVAS', 'MAP', 'AREA', 'SVG', 'IFRAME', 'FRAME', 'FRAMESET',
|
||||
'LI', 'UL', 'OL', 'DL', 'DT', 'DD', 'HR', 'P', 'PRE', 'LISTING', 'PLAINTEXT', 'A'
|
||||
];
|
||||
|
||||
while (element.parentElement) {
|
||||
const parentRect = element.parentElement.getBoundingClientRect();
|
||||
const childRect = element.getBoundingClientRect();
|
||||
const fullyContained =
|
||||
parentRect.left <= childRect.left &&
|
||||
parentRect.right >= childRect.right &&
|
||||
parentRect.top <= childRect.top &&
|
||||
parentRect.bottom >= childRect.bottom;
|
||||
|
||||
if (!containerTags.includes(element.parentElement.tagName)) {
|
||||
break;
|
||||
const significantOverlap =
|
||||
(childRect.width * childRect.height) /
|
||||
(parentRect.width * parentRect.height) > 0.5;
|
||||
|
||||
if (fullyContained && significantOverlap) {
|
||||
element = element.parentElement;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// }
|
||||
|
||||
const generalSelector = getSelectorPath(element);
|
||||
return {
|
||||
generalSelector,
|
||||
};
|
||||
}, coordinates);
|
||||
return selectors || { generalSelector: '' };
|
||||
} else {
|
||||
console.log(`NON UNIQUE: MODE 2`)
|
||||
const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => {
|
||||
function getNonUniqueSelector(element: HTMLElement): string {
|
||||
let selector = element.tagName.toLowerCase();
|
||||
|
||||
if (element.className) {
|
||||
const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
||||
if (classes.length > 0) {
|
||||
const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
||||
if (validClasses.length > 0) {
|
||||
selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
const fullyContained =
|
||||
parentRect.left <= childRect.left &&
|
||||
parentRect.right >= childRect.right &&
|
||||
parentRect.top <= childRect.top &&
|
||||
parentRect.bottom >= childRect.bottom;
|
||||
function getSelectorPath(element: HTMLElement | null): string {
|
||||
const path: string[] = [];
|
||||
let depth = 0;
|
||||
const maxDepth = 2;
|
||||
|
||||
const significantOverlap =
|
||||
(childRect.width * childRect.height) /
|
||||
(parentRect.width * parentRect.height) > 0.5;
|
||||
while (element && element !== document.body && depth < maxDepth) {
|
||||
const selector = getNonUniqueSelector(element);
|
||||
path.unshift(selector);
|
||||
element = element.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
if (fullyContained && significantOverlap) {
|
||||
element = element.parentElement;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
const originalEl = document.elementFromPoint(x, y) as HTMLElement;
|
||||
if (!originalEl) return null;
|
||||
|
||||
let element = originalEl;
|
||||
|
||||
const generalSelector = getSelectorPath(element);
|
||||
return {
|
||||
generalSelector,
|
||||
};
|
||||
}, coordinates);
|
||||
return selectors || { generalSelector: '' };
|
||||
}
|
||||
|
||||
const generalSelector = getSelectorPath(element);
|
||||
return {
|
||||
generalSelector,
|
||||
};
|
||||
}, coordinates);
|
||||
|
||||
return selectors || { generalSelector: '' };
|
||||
} catch (error) {
|
||||
console.error('Error in getNonUniqueSelectors:', error);
|
||||
return { generalSelector: '' };
|
||||
|
||||
74
src/components/atoms/DatePicker.tsx
Normal file
74
src/components/atoms/DatePicker.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSocketStore } from '../../context/socket';
|
||||
import { Coordinates } from './canvas';
|
||||
|
||||
interface DatePickerProps {
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DatePicker: React.FC<DatePickerProps> = ({ coordinates, selector, onClose }) => {
|
||||
const { socket } = useSocketStore();
|
||||
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSelectedDate(e.target.value);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (socket && selectedDate) {
|
||||
socket.emit('input:date', {
|
||||
selector,
|
||||
value: selectedDate
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${coordinates.x}px`,
|
||||
top: `${coordinates.y}px`,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
|
||||
padding: '10px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<input
|
||||
type="date"
|
||||
onChange={handleDateChange}
|
||||
value={selectedDate}
|
||||
className="p-2 border rounded"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 border rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedDate}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
selectedDate
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatePicker;
|
||||
74
src/components/atoms/DateTimeLocalPicker.tsx
Normal file
74
src/components/atoms/DateTimeLocalPicker.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSocketStore } from '../../context/socket';
|
||||
import { Coordinates } from './canvas';
|
||||
|
||||
interface DateTimeLocalPickerProps {
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DateTimeLocalPicker: React.FC<DateTimeLocalPickerProps> = ({ coordinates, selector, onClose }) => {
|
||||
const { socket } = useSocketStore();
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<string>('');
|
||||
|
||||
const handleDateTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSelectedDateTime(e.target.value);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (socket && selectedDateTime) {
|
||||
socket.emit('input:datetime-local', {
|
||||
selector,
|
||||
value: selectedDateTime
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${coordinates.x}px`,
|
||||
top: `${coordinates.y}px`,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
|
||||
padding: '10px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
onChange={handleDateTimeChange}
|
||||
value={selectedDateTime}
|
||||
className="p-2 border rounded"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 border rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedDateTime}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
selectedDateTime
|
||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateTimeLocalPicker;
|
||||
85
src/components/atoms/Dropdown.tsx
Normal file
85
src/components/atoms/Dropdown.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSocketStore } from '../../context/socket';
|
||||
import { Coordinates } from './canvas';
|
||||
|
||||
interface DropdownProps {
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
options: Array<{
|
||||
value: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
selected: boolean;
|
||||
}>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Dropdown = ({ coordinates, selector, options, onClose }: DropdownProps) => {
|
||||
const { socket } = useSocketStore();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (socket) {
|
||||
socket.emit('input:dropdown', { selector, value });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: coordinates.x,
|
||||
top: coordinates.y,
|
||||
zIndex: 1000,
|
||||
width: '200px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid rgb(169, 169, 169)',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
|
||||
};
|
||||
|
||||
const scrollContainerStyle: React.CSSProperties = {
|
||||
maxHeight: '180px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
};
|
||||
|
||||
const getOptionStyle = (option: any, index: number): React.CSSProperties => ({
|
||||
fontSize: '13.333px',
|
||||
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',
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={containerStyle}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div style={scrollContainerStyle}>
|
||||
{options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={getOptionStyle(option, index)}
|
||||
onMouseEnter={() => !option.disabled && setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => !option.disabled && handleSelect(option.value)}
|
||||
>
|
||||
{option.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
130
src/components/atoms/TimePicker.tsx
Normal file
130
src/components/atoms/TimePicker.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSocketStore } from '../../context/socket';
|
||||
import { Coordinates } from './canvas';
|
||||
|
||||
interface TimePickerProps {
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TimePicker = ({ coordinates, selector, onClose }: TimePickerProps) => {
|
||||
const { socket } = useSocketStore();
|
||||
const [hoveredHour, setHoveredHour] = useState<number | null>(null);
|
||||
const [hoveredMinute, setHoveredMinute] = useState<number | null>(null);
|
||||
const [selectedHour, setSelectedHour] = useState<number | null>(null);
|
||||
const [selectedMinute, setSelectedMinute] = useState<number | null>(null);
|
||||
|
||||
const handleHourSelect = (hour: number) => {
|
||||
setSelectedHour(hour);
|
||||
// If minute is already selected, complete the selection
|
||||
if (selectedMinute !== null) {
|
||||
const formattedHour = hour.toString().padStart(2, '0');
|
||||
const formattedMinute = selectedMinute.toString().padStart(2, '0');
|
||||
if (socket) {
|
||||
socket.emit('input:time', {
|
||||
selector,
|
||||
value: `${formattedHour}:${formattedMinute}`
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinuteSelect = (minute: number) => {
|
||||
setSelectedMinute(minute);
|
||||
// If hour is already selected, complete the selection
|
||||
if (selectedHour !== null) {
|
||||
const formattedHour = selectedHour.toString().padStart(2, '0');
|
||||
const formattedMinute = minute.toString().padStart(2, '0');
|
||||
if (socket) {
|
||||
socket.emit('input:time', {
|
||||
selector,
|
||||
value: `${formattedHour}:${formattedMinute}`
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: coordinates.x,
|
||||
top: coordinates.y,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid rgb(169, 169, 169)',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
|
||||
};
|
||||
|
||||
const columnStyle: React.CSSProperties = {
|
||||
width: '60px',
|
||||
maxHeight: '180px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
borderRight: '1px solid rgb(169, 169, 169)',
|
||||
};
|
||||
|
||||
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',
|
||||
padding: '0 3px',
|
||||
cursor: 'default',
|
||||
backgroundColor: isSelected ? '#0078D7' : isHovered ? '#0078D7' : 'white',
|
||||
color: (isSelected || isHovered) ? 'white' : 'black',
|
||||
userSelect: 'none',
|
||||
};
|
||||
};
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const minutes = Array.from({ length: 60 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={containerStyle}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Hours column */}
|
||||
<div style={columnStyle}>
|
||||
{hours.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
style={getOptionStyle(hour, true)}
|
||||
onMouseEnter={() => setHoveredHour(hour)}
|
||||
onMouseLeave={() => setHoveredHour(null)}
|
||||
onClick={() => handleHourSelect(hour)}
|
||||
>
|
||||
{hour.toString().padStart(2, '0')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Minutes column */}
|
||||
<div style={{...columnStyle, borderRight: 'none'}}>
|
||||
{minutes.map((minute) => (
|
||||
<div
|
||||
key={minute}
|
||||
style={getOptionStyle(minute, false)}
|
||||
onMouseEnter={() => setHoveredMinute(minute)}
|
||||
onMouseLeave={() => setHoveredMinute(null)}
|
||||
onClick={() => handleMinuteSelect(minute)}
|
||||
>
|
||||
{minute.toString().padStart(2, '0')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimePicker;
|
||||
@@ -3,6 +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';
|
||||
|
||||
interface CreateRefCallback {
|
||||
(ref: React.RefObject<HTMLCanvasElement>): void;
|
||||
@@ -31,6 +35,32 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
||||
const getTextRef = useRef(getText);
|
||||
const getListRef = useRef(getList);
|
||||
|
||||
const [datePickerInfo, setDatePickerInfo] = React.useState<{
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
} | null>(null);
|
||||
|
||||
const [dropdownInfo, setDropdownInfo] = React.useState<{
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
options: Array<{
|
||||
value: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
selected: boolean;
|
||||
}>;
|
||||
} | null>(null);
|
||||
|
||||
const [timePickerInfo, setTimePickerInfo] = React.useState<{
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
} | null>(null);
|
||||
|
||||
const [dateTimeLocalInfo, setDateTimeLocalInfo] = React.useState<{
|
||||
coordinates: Coordinates;
|
||||
selector: string;
|
||||
} | null>(null);
|
||||
|
||||
const notifyLastAction = (action: string) => {
|
||||
if (lastAction !== action) {
|
||||
setLastAction(action);
|
||||
@@ -44,6 +74,42 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
||||
getListRef.current = getList;
|
||||
}, [getText, getList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on('showDatePicker', (info: {coordinates: Coordinates, selector: string}) => {
|
||||
setDatePickerInfo(info);
|
||||
});
|
||||
|
||||
socket.on('showDropdown', (info: {
|
||||
coordinates: Coordinates,
|
||||
selector: string,
|
||||
options: Array<{
|
||||
value: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
selected: boolean;
|
||||
}>;
|
||||
}) => {
|
||||
setDropdownInfo(info);
|
||||
});
|
||||
|
||||
socket.on('showTimePicker', (info: {coordinates: Coordinates, selector: string}) => {
|
||||
setTimePickerInfo(info);
|
||||
});
|
||||
|
||||
socket.on('showDateTimePicker', (info: {coordinates: Coordinates, selector: string}) => {
|
||||
setDateTimeLocalInfo(info);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('showDatePicker');
|
||||
socket.off('showDropdown');
|
||||
socket.off('showTimePicker');
|
||||
socket.off('showDateTimePicker');
|
||||
};
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const onMouseEvent = useCallback((event: MouseEvent) => {
|
||||
if (socket && canvasRef.current) {
|
||||
// Get the canvas bounding rectangle
|
||||
@@ -146,6 +212,35 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
||||
width={900}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
{datePickerInfo && (
|
||||
<DatePicker
|
||||
coordinates={datePickerInfo.coordinates}
|
||||
selector={datePickerInfo.selector}
|
||||
onClose={() => setDatePickerInfo(null)}
|
||||
/>
|
||||
)}
|
||||
{dropdownInfo && (
|
||||
<Dropdown
|
||||
coordinates={dropdownInfo.coordinates}
|
||||
selector={dropdownInfo.selector}
|
||||
options={dropdownInfo.options}
|
||||
onClose={() => setDropdownInfo(null)}
|
||||
/>
|
||||
)}
|
||||
{timePickerInfo && (
|
||||
<TimePicker
|
||||
coordinates={timePickerInfo.coordinates}
|
||||
selector={timePickerInfo.selector}
|
||||
onClose={() => setTimePickerInfo(null)}
|
||||
/>
|
||||
)}
|
||||
{dateTimeLocalInfo && (
|
||||
<DateTimeLocalPicker
|
||||
coordinates={dateTimeLocalInfo.coordinates}
|
||||
selector={dateTimeLocalInfo.selector}
|
||||
onClose={() => setDateTimeLocalInfo(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
import { useTranslation } from "react-i18next"; // Import useTranslation hook
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
@@ -62,7 +50,7 @@ export const NavBar: React.FC<NavBarProps> = ({
|
||||
return version;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch latest version:", error);
|
||||
return null; // Handle errors gracefully
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,297 +100,207 @@ export const NavBar: React.FC<NavBarProps> = ({
|
||||
};
|
||||
|
||||
const changeLanguage = (lang: string) => {
|
||||
i18n.changeLanguage(lang); // Change language dynamically
|
||||
localStorage.setItem("language", lang); // Persist language to localStorage
|
||||
i18n.changeLanguage(lang);
|
||||
localStorage.setItem("language", lang);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkForUpdates = async () => {
|
||||
const latestVersion = await fetchLatestVersion();
|
||||
setLatestVersion(latestVersion); // Set the latest version state
|
||||
setLatestVersion(latestVersion);
|
||||
if (latestVersion && latestVersion !== currentVersion) {
|
||||
setIsUpdateAvailable(true); // Show a notification or highlight the "Upgrade" button
|
||||
setIsUpdateAvailable(true);
|
||||
}
|
||||
};
|
||||
checkForUpdates();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
<NavBarWrapper>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={MaxunLogo}
|
||||
width={45}
|
||||
height={40}
|
||||
style={{ borderRadius: "5px", margin: "5px 0px 5px 15px" }}
|
||||
/>
|
||||
<div style={{ padding: "11px" }}>
|
||||
<ProjectName>Maxun</ProjectName>
|
||||
<NavBarWrapper>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
}}>
|
||||
<img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} />
|
||||
<div style={{ padding: '11px' }}><ProjectName>Maxun</ProjectName></div>
|
||||
<Chip
|
||||
label={`${currentVersion}`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ marginTop: '10px' }}
|
||||
/>
|
||||
</div>
|
||||
<Chip
|
||||
label="beta"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ marginTop: "10px" }}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
{user ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{!isRecording ? (
|
||||
<>
|
||||
<IconButton
|
||||
component="a"
|
||||
href="https://discord.gg/5GbPjBUkws"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "5px",
|
||||
padding: "8px",
|
||||
marginRight: "30px",
|
||||
}}
|
||||
>
|
||||
<DiscordIcon sx={{ marginRight: "5px" }} />
|
||||
</IconButton>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large"
|
||||
frameBorder="0"
|
||||
scrolling="0"
|
||||
width="170"
|
||||
height="30"
|
||||
title="GitHub"
|
||||
></iframe>
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "5px",
|
||||
padding: "8px",
|
||||
marginRight: "10px",
|
||||
"&:hover": { backgroundColor: "white", color: "#ff00c3" },
|
||||
}}
|
||||
>
|
||||
<AccountCircle sx={{ marginRight: "5px" }} />
|
||||
<Typography variant="body1">{user.email}</Typography>
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleMenuClose();
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<Logout sx={{ marginRight: "5px" }} /> {t("logout")}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={goToMainMenu}
|
||||
sx={{
|
||||
borderRadius: "5px",
|
||||
padding: "8px",
|
||||
background: "red",
|
||||
color: "white",
|
||||
marginRight: "10px",
|
||||
"&:hover": { color: "white", backgroundColor: "red" },
|
||||
}}
|
||||
>
|
||||
<Clear sx={{ marginRight: "5px" }} />
|
||||
{t("discard")}
|
||||
</IconButton>
|
||||
<SaveRecording fileName={recordingName} />
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleLangMenuOpen}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "5px",
|
||||
padding: "8px",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">
|
||||
<Language />
|
||||
</Typography>
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={langAnchorEl}
|
||||
open={Boolean(langAnchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("en");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("es");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
Español
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("ja");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
日本語
|
||||
</MenuItem>
|
||||
{/* <MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("ar");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
العربية
|
||||
</MenuItem> */}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("zh");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
中文
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("de");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
Deutsch
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
) : (
|
||||
<><IconButton
|
||||
onClick={handleLangMenuOpen}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "5px",
|
||||
padding: "8px",
|
||||
marginRight: "10px",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1">{t("language")}</Typography>
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={langAnchorEl}
|
||||
open={Boolean(langAnchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("en");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
English
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("es");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
Español
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("ja");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
日本語
|
||||
</MenuItem>
|
||||
{/* <MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("ar");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
العربية
|
||||
</MenuItem> */}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("zh");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
中文
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
changeLanguage("de");
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
Deutsch
|
||||
</MenuItem>
|
||||
</Menu></>
|
||||
)}
|
||||
|
||||
|
||||
</NavBarWrapper>
|
||||
|
||||
|
||||
{
|
||||
user ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||
{!isRecording ? (
|
||||
<>
|
||||
<Button variant="outlined" onClick={handleUpdateOpen} sx={{
|
||||
marginRight: '40px',
|
||||
color: "#00000099",
|
||||
border: "#00000099 1px solid",
|
||||
'&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' }
|
||||
}}>
|
||||
<Update sx={{ marginRight: '5px' }} /> Upgrade Maxun
|
||||
</Button>
|
||||
<Modal open={open} onClose={handleUpdateClose}>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 500,
|
||||
bgcolor: "background.paper",
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{latestVersion === null ? (
|
||||
<Typography>Checking for updates...</Typography>
|
||||
) : currentVersion === latestVersion ? (
|
||||
<Typography variant="h6" textAlign="center">
|
||||
🎉 You're up to date!
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body1" textAlign="left" sx={{ marginLeft: '30px' }}>
|
||||
A new version is available: {latestVersion}. Upgrade to the latest version for bug fixes, enhancements and new features!
|
||||
<br />
|
||||
View all the new updates
|
||||
<a href="https://github.com/getmaxun/maxun/releases/" target="_blank" style={{ textDecoration: 'none' }}>{' '}here.</a>
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={handleUpdateTabChange}
|
||||
sx={{ marginTop: 2, marginBottom: 2 }}
|
||||
centered
|
||||
>
|
||||
<Tab label="Manual Setup Upgrade" />
|
||||
<Tab label="Docker Compose Setup Upgrade" />
|
||||
</Tabs>
|
||||
{tab === 0 && (
|
||||
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
|
||||
<code style={{ color: 'black' }}>
|
||||
<p>Run the commands below</p>
|
||||
# cd to project directory (eg: maxun)
|
||||
<br />
|
||||
cd maxun
|
||||
<br />
|
||||
<br />
|
||||
# pull latest changes
|
||||
<br />
|
||||
git pull origin master
|
||||
<br />
|
||||
<br />
|
||||
# install dependencies
|
||||
<br />
|
||||
npm install
|
||||
<br />
|
||||
<br />
|
||||
# start maxun
|
||||
<br />
|
||||
npm run start
|
||||
</code>
|
||||
</Box>
|
||||
)}
|
||||
{tab === 1 && (
|
||||
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
|
||||
<code style={{ color: 'black' }}>
|
||||
<p>Run the commands below</p>
|
||||
# cd to project directory (eg: maxun)
|
||||
<br />
|
||||
cd maxun
|
||||
<br />
|
||||
<br />
|
||||
# stop the working containers
|
||||
<br />
|
||||
docker-compose down
|
||||
<br />
|
||||
<br />
|
||||
# pull latest docker images
|
||||
<br />
|
||||
docker-compose pull
|
||||
<br />
|
||||
<br />
|
||||
# start maxun
|
||||
<br />
|
||||
docker-compose up -d
|
||||
</code>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe>
|
||||
<IconButton onClick={handleMenuOpen} sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '5px',
|
||||
padding: '8px',
|
||||
marginRight: '10px',
|
||||
'&:hover': { backgroundColor: 'white', color: '#ff00c3' }
|
||||
}}>
|
||||
<AccountCircle sx={{ marginRight: '5px' }} />
|
||||
<Typography variant="body1">{user.email}</Typography>
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
PaperProps={{ sx: { width: '180px' } }}
|
||||
>
|
||||
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
|
||||
<Logout sx={{ marginRight: '5px' }} /> Logout
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
window.open('https://discord.gg/5GbPjBUkws', '_blank');
|
||||
}}>
|
||||
<DiscordIcon sx={{ marginRight: '5px' }} /> Discord
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
window.open('https://www.youtube.com/@MaxunOSS/videos?ref=app', '_blank');
|
||||
}}>
|
||||
<YouTube sx={{ marginRight: '5px' }} /> YouTube
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
window.open('https://x.com/maxun_io?ref=app', '_blank');
|
||||
}}>
|
||||
<X sx={{ marginRight: '5px' }} /> Twiiter (X)
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconButton onClick={goToMainMenu} sx={{
|
||||
borderRadius: '5px',
|
||||
padding: '8px',
|
||||
background: 'red',
|
||||
color: 'white',
|
||||
marginRight: '10px',
|
||||
'&:hover': { color: 'white', backgroundColor: 'red' }
|
||||
}}>
|
||||
<Clear sx={{ marginRight: '5px' }} />
|
||||
Discard
|
||||
</IconButton>
|
||||
<SaveRecording fileName={recordingName} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : ""
|
||||
}
|
||||
</NavBarWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useReducer, createContext, useEffect } from 'react';
|
||||
import { useReducer, createContext, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiUrl } from "../apiConfig";
|
||||
@@ -14,12 +14,16 @@ interface ActionType {
|
||||
|
||||
type InitialStateType = {
|
||||
user: any;
|
||||
lastActivityTime?: number;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
user: null,
|
||||
lastActivityTime: Date.now(),
|
||||
};
|
||||
|
||||
const AUTO_LOGOUT_TIME = 4 * 60 * 60 * 1000; // 4 hours in milliseconds
|
||||
|
||||
const AuthContext = createContext<{
|
||||
state: InitialStateType;
|
||||
dispatch: React.Dispatch<ActionType>;
|
||||
@@ -34,11 +38,13 @@ const reducer = (state: InitialStateType, action: ActionType) => {
|
||||
return {
|
||||
...state,
|
||||
user: action.payload,
|
||||
lastActivityTime: Date.now(),
|
||||
};
|
||||
case 'LOGOUT':
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
lastActivityTime: undefined,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
@@ -50,6 +56,39 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const navigate = useNavigate();
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await axios.get(`${apiUrl}/auth/logout`);
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
window.localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const checkAutoLogout = useCallback(() => {
|
||||
if (state.user && state.lastActivityTime) {
|
||||
const currentTime = Date.now();
|
||||
const timeSinceLastActivity = currentTime - state.lastActivityTime;
|
||||
|
||||
if (timeSinceLastActivity >= AUTO_LOGOUT_TIME) {
|
||||
handleLogout();
|
||||
}
|
||||
}
|
||||
}, [state.user, state.lastActivityTime, handleLogout]);
|
||||
|
||||
// Update last activity time on user interactions
|
||||
const updateActivityTime = useCallback(() => {
|
||||
if (state.user) {
|
||||
dispatch({
|
||||
type: 'LOGIN',
|
||||
payload: state.user // Reuse existing user data
|
||||
});
|
||||
}
|
||||
}, [state.user]);
|
||||
|
||||
// Initialize user from localStorage
|
||||
useEffect(() => {
|
||||
const storedUser = window.localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
@@ -57,21 +96,54 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set up activity listeners
|
||||
useEffect(() => {
|
||||
if (state.user) {
|
||||
// List of events to track for user activity
|
||||
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||
|
||||
// Throttled event handler
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const handleActivity = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(updateActivityTime, 1000);
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
events.forEach(event => {
|
||||
window.addEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
// Set up periodic check for auto logout
|
||||
const checkInterval = setInterval(checkAutoLogout, 60000); // Check every minute
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
events.forEach(event => {
|
||||
window.removeEventListener(event, handleActivity);
|
||||
});
|
||||
clearInterval(checkInterval);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [state.user, updateActivityTime, checkAutoLogout]);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
function (response) {
|
||||
return response;
|
||||
},
|
||||
function (error) {
|
||||
const res = error.response;
|
||||
if (res.status === 401 && res.config && !res.config.__isRetryRequest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.get(`${apiUrl}/auth/logout`)
|
||||
if (res?.status === 401 && res.config && !res.config.__isRetryRequest) {
|
||||
return new Promise((_, reject) => {
|
||||
handleLogout()
|
||||
.then(() => {
|
||||
console.log('/401 error > logout');
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
window.localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
reject(error);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('AXIOS INTERCEPTORS ERROR:', err);
|
||||
|
||||
@@ -14,8 +14,8 @@ interface ActionContextProps {
|
||||
paginationType: PaginationType;
|
||||
limitType: LimitType;
|
||||
customLimit: string;
|
||||
captureStage: CaptureStage; // New captureStage property
|
||||
setCaptureStage: (stage: CaptureStage) => void; // Setter for captureStage
|
||||
captureStage: CaptureStage;
|
||||
setCaptureStage: (stage: CaptureStage) => void;
|
||||
startPaginationMode: () => void;
|
||||
startGetText: () => void;
|
||||
stopGetText: () => void;
|
||||
|
||||
Reference in New Issue
Block a user