diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1477557 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +EXPO_PUBLIC_HOME_PAGE_URL=https://afnprojects.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index 99491cc..9408328 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ yarn-error.* # local env files .env*.local +.env # typescript *.tsbuildinfo diff --git a/.vscode/settings.json b/.vscode/settings.json index 310b919..ae7681a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ }, "typescript.tsdk": "node_modules/typescript/lib", "cSpell.words": [ + "persistor", "scriptian" ] } diff --git a/__tests__/Index-test.tsx b/__tests__/Index-test.tsx index 7447119..c402fe3 100644 --- a/__tests__/Index-test.tsx +++ b/__tests__/Index-test.tsx @@ -1,6 +1,6 @@ import { render } from "@testing-library/react-native"; -import Index from "@/app/index"; +import Index from "@/src/app/index"; describe("", () => { test("Text renders correctly on Index", () => { diff --git a/app/_layout.tsx b/app/_layout.tsx deleted file mode 100644 index d2a8b0b..0000000 --- a/app/_layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Stack } from "expo-router"; - -export default function RootLayout() { - return ; -} diff --git a/package-lock.json b/package-lock.json index a59c388..4100082 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.0.1", "dependencies": { "@expo/vector-icons": "^15.0.2", + "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@reduxjs/toolkit": "^2.9.0", "expo": "~54.0.13", "expo-constants": "~18.0.9", "expo-dev-client": "~6.0.15", @@ -34,7 +36,9 @@ "react-native-screens": "~4.16.0", "react-native-web": "~0.21.0", "react-native-webview": "13.15.0", - "react-native-worklets": "0.5.1" + "react-native-worklets": "0.5.1", + "react-redux": "^9.2.0", + "redux-persist": "^6.0.0" }, "devDependencies": { "@testing-library/react-native": "^13.3.3", @@ -3199,6 +3203,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.4", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.4.tgz", @@ -3543,6 +3559,32 @@ "nanoid": "^3.3.11" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", + "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3574,6 +3616,18 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@testing-library/react-native": { "version": "13.3.3", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", @@ -3900,6 +3954,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -9241,6 +9301,16 @@ "node": ">=16.x" } }, + "node_modules/immer": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", + "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9680,6 +9750,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -11627,6 +11706,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -13479,6 +13570,29 @@ "async-limiter": "~1.0.0" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -13605,6 +13719,30 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "license": "MIT", + "peerDependencies": { + "redux": ">4.0.0" + } + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13755,6 +13893,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/package.json b/package.json index 5447521..3fb4daf 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.2", + "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@reduxjs/toolkit": "^2.9.0", "expo": "~54.0.13", "expo-constants": "~18.0.9", "expo-dev-client": "~6.0.15", @@ -38,7 +40,9 @@ "react-native-screens": "~4.16.0", "react-native-web": "~0.21.0", "react-native-webview": "13.15.0", - "react-native-worklets": "0.5.1" + "react-native-worklets": "0.5.1", + "react-redux": "^9.2.0", + "redux-persist": "^6.0.0" }, "devDependencies": { "@testing-library/react-native": "^13.3.3", @@ -55,4 +59,4 @@ "jest": { "preset": "jest-expo" } -} \ No newline at end of file +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx new file mode 100644 index 0000000..58a5ff5 --- /dev/null +++ b/src/app/_layout.tsx @@ -0,0 +1,33 @@ +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import React from "react"; +import { ActivityIndicator, Text, View } from "react-native"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { Provider } from "react-redux"; +import { PersistGate } from "redux-persist/integration/react"; + +import { persistor, store } from "../store"; + +const LoadingComponent = () => ( + + + + Loading Scriptian... + + +); + +export default function RootLayout() { + return ( + + } persistor={persistor}> + + + + + + + + + ); +} diff --git a/app/index.tsx b/src/app/index.tsx similarity index 100% rename from app/index.tsx rename to src/app/index.tsx diff --git a/src/features/browser/browserSlice.ts b/src/features/browser/browserSlice.ts new file mode 100644 index 0000000..76df1c5 --- /dev/null +++ b/src/features/browser/browserSlice.ts @@ -0,0 +1,118 @@ +import { createSlice, nanoid, PayloadAction } from "@reduxjs/toolkit"; +import { BrowserState, BrowserTab } from "./types"; + +const homePageUrl = + process.env.EXPO_PUBLIC_HOME_PAGE_URL || "https://afnprojects.com"; + +const initialState: BrowserState = { + tabs: [ + { + id: "1", + url: homePageUrl, + title: "Ali Fuat Numanoglu's Projects", + isLoading: false, + canGoBack: false, + canGoForward: false, + activeScripts: [], + }, + ], + activeTabId: "1", + showTabs: false, + bookmarks: [], +}; + +const browserSlice = createSlice({ + name: "browser", + initialState, + reducers: { + addTab: (state, action: PayloadAction<{ url?: string }>) => { + const newTab: BrowserTab = { + id: nanoid(), + url: action.payload.url || homePageUrl, + title: "New Tab", + isLoading: false, + canGoBack: false, + canGoForward: false, + activeScripts: [], + }; + state.tabs.push(newTab); + state.activeTabId = newTab.id; + state.showTabs = false; + }, + + closeTab: (state, action: PayloadAction) => { + const tabId = action.payload; + state.tabs = state.tabs.filter((tab) => tab.id !== tabId); + + if (state.activeTabId === tabId && state.tabs.length > 0) { + state.activeTabId = state.tabs[0].id; + } + + if (state.tabs.length <= 1) { + state.showTabs = false; + } + }, + + updateTab: ( + state, + action: PayloadAction<{ + tabId: string; + updates: Partial; + }> + ) => { + const { tabId, updates } = action.payload; + const tab = state.tabs.find((t) => t.id === tabId); + if (tab) { + Object.assign(tab, updates); + } + }, + + switchTab: (state, action: PayloadAction) => { + state.activeTabId = action.payload; + state.showTabs = false; + }, + + toggleTabView: (state) => { + state.showTabs = !state.showTabs; + }, + + navigateToUrl: ( + state, + action: PayloadAction<{ tabId: string; url: string }> + ) => { + const { tabId, url } = action.payload; + const tab = state.tabs.find((t) => t.id === tabId); + if (tab) { + tab.url = url; + tab.isLoading = true; + tab.canGoBack = false; + tab.canGoForward = false; + } + }, + + addBookmark: (state, action: PayloadAction) => { + if (!state.bookmarks.includes(action.payload)) { + state.bookmarks.push(action.payload); + } + }, + + removeBookmark: (state, action: PayloadAction) => { + state.bookmarks = state.bookmarks.filter( + (bookmark) => bookmark !== action.payload + ); + }, + }, +}); + +export const { + addTab, + closeTab, + updateTab, + switchTab, + toggleTabView, + navigateToUrl, + addBookmark, + removeBookmark, +} = browserSlice.actions; + +export default browserSlice.reducer; diff --git a/src/features/browser/types.ts b/src/features/browser/types.ts new file mode 100644 index 0000000..74bac0e --- /dev/null +++ b/src/features/browser/types.ts @@ -0,0 +1,17 @@ +export interface BrowserTab { + id: string; + url: string; + title: string; + favicon?: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + activeScripts: string[]; +} + +export interface BrowserState { + tabs: BrowserTab[]; + activeTabId: string; + showTabs: boolean; + bookmarks: string[]; +} diff --git a/src/features/scripts/api/scriptApi.ts b/src/features/scripts/api/scriptApi.ts new file mode 100644 index 0000000..7f06d0d --- /dev/null +++ b/src/features/scripts/api/scriptApi.ts @@ -0,0 +1,28 @@ +/** + * Fetches and returns the contents of a script from a given URL. + * @param url - The URL of the script to import. + * @returns The script code as a string. + * @throws If the fetch fails or the URL is invalid. + */ +export async function importScript(url: string): Promise { + if (!url || typeof url !== "string") { + throw new TypeError("A valid URL string must be provided."); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error( + `Failed to import script: ${response.status} ${response.statusText}` + ); + } + return await response.text(); + } catch (error) { + throw new Error(`Error importing script: ${(error as Error).message}`); + } finally { + clearTimeout(timeout); + } +} diff --git a/src/features/scripts/scriptsSlice.ts b/src/features/scripts/scriptsSlice.ts new file mode 100644 index 0000000..6b1cacd --- /dev/null +++ b/src/features/scripts/scriptsSlice.ts @@ -0,0 +1,165 @@ +import { + createAsyncThunk, + createSlice, + nanoid, + PayloadAction, +} from "@reduxjs/toolkit"; +import { importScript } from "./api/scriptApi"; +import { ScriptExecution, ScriptsState, UserScript } from "./types"; +import { validateScript } from "./utils"; + +// Async thunk to add a new user script by importing from a URL or direct input +export const addUserScript = createAsyncThunk< + UserScript, + { + url?: string; + code?: string; + name: string; + description: string; + urlPatterns: string[]; + runAt: "document-start" | "document-ready" | "document-end"; + }, + { rejectValue: string } +>("scripts/createUserScript", async (args, { rejectWithValue }) => { + try { + let code: string | undefined; + + if (args.code) { + code = args.code; + } else if (args.url) { + code = await importScript(args.url); + } else { + return rejectWithValue("Either 'url' or 'code' must be provided."); + } + + // Validate the script code before creating the script + const validation = validateScript(code); + if (!validation.valid) { + return rejectWithValue(validation.error ?? "Unknown validation error"); + } + + const now = new Date().toISOString(); + return { + id: nanoid(), + name: args.name, + description: args.description, + runAt: args.runAt, + code, + urlPatterns: args.urlPatterns, + enabled: true, + createdAt: now, + updatedAt: now, + }; + } catch (error) { + return rejectWithValue((error as Error).message); + } +}); + +const initialState: ScriptsState = { + userScripts: {}, + executions: {}, + isLoading: false, + error: null, +}; + +const scriptsSlice = createSlice({ + name: "scripts", + initialState, + reducers: { + updateUserScript: { + reducer: ( + state, + action: PayloadAction<{ + id: string; + updates: Partial; + updatedAt: string; + }> + ) => { + const { id, updates, updatedAt } = action.payload; + const script = state.userScripts[id]; + if (script) { + Object.assign(script, { ...updates, updatedAt }); + } + }, + prepare: (id: string, updates: Partial) => { + return { + payload: { + id, + updates, + updatedAt: new Date().toISOString(), + }, + }; + }, + }, + + deleteUserScript: (state, action: PayloadAction) => { + const scriptId = action.payload; + // Remove the script + delete state.userScripts[scriptId]; + // Also remove related execution logs + Object.entries(state.executions).forEach(([id, execution]) => { + if (execution.scriptId === scriptId) { + delete state.executions[id]; + } + }); + }, + + toggleUserScript: { + reducer: ( + state, + action: PayloadAction<{ id: string; updatedAt: string }> + ) => { + const script = state.userScripts[action.payload.id]; + if (script) { + script.enabled = !script.enabled; + script.updatedAt = action.payload.updatedAt; + } + }, + prepare: (id: string) => ({ + payload: { + id, + updatedAt: new Date().toISOString(), + }, + }), + }, + + logScriptExecution: (state, action: PayloadAction) => { + state.executions[action.payload.id] = action.payload; + }, + + clearExecutionLogs: (state) => { + state.executions = {}; + }, + + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + }, + + extraReducers: (builder) => { + builder + .addCase(addUserScript.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase(addUserScript.fulfilled, (state, action) => { + state.isLoading = false; + state.userScripts[action.payload.id] = action.payload; + }) + .addCase(addUserScript.rejected, (state, action) => { + state.isLoading = false; + state.error = action.error.message || "Script import failed!"; + }); + }, +}); + +export const { + updateUserScript, + deleteUserScript, + toggleUserScript, + logScriptExecution, + clearExecutionLogs, + setError, +} = scriptsSlice.actions; + +export default scriptsSlice.reducer; diff --git a/src/features/scripts/types.ts b/src/features/scripts/types.ts new file mode 100644 index 0000000..b8e5c50 --- /dev/null +++ b/src/features/scripts/types.ts @@ -0,0 +1,28 @@ +export interface UserScript { + id: string; + name: string; + description?: string; + code: string; + urlPatterns: string[]; + enabled: boolean; + runAt: "document-start" | "document-ready" | "document-end"; + createdAt: string; + updatedAt: string; +} + +export interface ScriptExecution { + id: string; + scriptId: string; + url: string; + timestamp: string; + success: boolean; + error?: string; + executionTime?: number; +} + +export interface ScriptsState { + userScripts: Record; + executions: Record; + isLoading: boolean; + error: string | null; +} diff --git a/src/features/scripts/utils/index.ts b/src/features/scripts/utils/index.ts new file mode 100644 index 0000000..38107e4 --- /dev/null +++ b/src/features/scripts/utils/index.ts @@ -0,0 +1,21 @@ +export interface ScriptValidationResult { + valid: boolean; + error: string | null; +} + +/** + * Validates the syntax of a given script code string. + * + * @param code - The script code to validate. + * @returns An object containing the validation result: + * - `valid`: `true` if the code is syntactically correct, `false` otherwise. + * - `error`: The error message if validation fails, or `null` if validation succeeds. + */ +export function validateScript(code: string): ScriptValidationResult { + try { + new Function(code); // Basic syntax check + return { valid: true, error: null }; + } catch (error) { + return { valid: false, error: (error as Error).message }; + } +} diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts new file mode 100644 index 0000000..c46cafa --- /dev/null +++ b/src/features/settings/settingsSlice.ts @@ -0,0 +1,56 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { SettingsState } from "./types"; + +const initialState: SettingsState = { + scriptsEnabled: true, + maxExecutionTime: 5000, + logExecutions: true, + theme: "light", + autoUpdateScripts: false, +}; + +const settingsSlice = createSlice({ + name: "settings", + initialState, + reducers: { + updateSettings: (state, action: PayloadAction>) => { + Object.assign(state, action.payload); + }, + + toggleScriptsEnabled: (state) => { + state.scriptsEnabled = !state.scriptsEnabled; + }, + + setMaxExecutionTime: (state, action: PayloadAction) => { + state.maxExecutionTime = Math.max(1000, Math.min(30000, action.payload)); + }, + + toggleLogExecutions: (state) => { + state.logExecutions = !state.logExecutions; + }, + + setTheme: (state, action: PayloadAction<"light" | "dark">) => { + state.theme = action.payload; + }, + + toggleAutoUpdateScripts: (state) => { + state.autoUpdateScripts = !state.autoUpdateScripts; + }, + + resetSettings: (state) => { + Object.assign(state, initialState); + }, + }, +}); + +export const { + updateSettings, + toggleScriptsEnabled, + setMaxExecutionTime, + toggleLogExecutions, + setTheme, + toggleAutoUpdateScripts, + resetSettings, +} = settingsSlice.actions; + +export default settingsSlice.reducer; diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts new file mode 100644 index 0000000..fe08929 --- /dev/null +++ b/src/features/settings/types.ts @@ -0,0 +1,7 @@ +export interface SettingsState { + scriptsEnabled: boolean; + maxExecutionTime: number; + logExecutions: boolean; + theme: "light" | "dark"; + autoUpdateScripts: boolean; +} diff --git a/src/hooks/useAppDispatch.ts b/src/hooks/useAppDispatch.ts new file mode 100644 index 0000000..48c0f38 --- /dev/null +++ b/src/hooks/useAppDispatch.ts @@ -0,0 +1,4 @@ +import { useDispatch } from "react-redux"; +import type { AppDispatch } from "../store"; + +export const useAppDispatch = () => useDispatch(); diff --git a/src/hooks/useAppSelector.ts b/src/hooks/useAppSelector.ts new file mode 100644 index 0000000..72f875a --- /dev/null +++ b/src/hooks/useAppSelector.ts @@ -0,0 +1,4 @@ +import { TypedUseSelectorHook, useSelector } from "react-redux"; +import type { RootState } from "../store"; + +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..de57f88 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,46 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import { + FLUSH, + PAUSE, + PERSIST, + persistReducer, + persistStore, + PURGE, + REGISTER, + REHYDRATE, +} from "redux-persist"; + +import browserReducer from "../features/browser/browserSlice"; +import scriptsReducer from "../features/scripts/scriptsSlice"; +import settingsReducer from "../features/settings/settingsSlice"; + +const persistConfig = { + key: "root", + storage: AsyncStorage, + whitelist: ["scripts", "settings"], +}; + +const rootReducer = combineReducers({ + browser: browserReducer, + scripts: scriptsReducer, + settings: settingsReducer, +}); + +const persistedReducer = persistReducer(persistConfig, rootReducer); + +export const store = configureStore({ + reducer: persistedReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }), + devTools: __DEV__, // Enable Redux DevTools only in development +}); + +export const persistor = persistStore(store); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..2f47367 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,9 @@ +import { BrowserState } from "../features/browser/types"; +import { ScriptsState } from "../features/scripts/types"; +import { SettingsState } from "../features/settings/types"; + +export interface RootState { + browser: BrowserState; + scripts: ScriptsState; + settings: SettingsState; +}