diff --git a/README.md b/README.md index 5997bcf..a2a964b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A simple JavaScript playground / code sandbox hooked up with few libraries for quick code testing -![Sample](https://user-images.githubusercontent.com/3861725/75946297-d6600380-5ef0-11ea-9b59-794ae8ec613b.png) +![Sample](images/js-playground.png) ## Available Libraries @@ -13,6 +13,7 @@ A simple JavaScript playground / code sandbox hooked up with few libraries for q - [Lodash](https://lodash.com/) - [Axios](https://github.com/axios/axios) - [Luxon](https://moment.github.io/luxon/#/) +- [date-fns](https://date-fns.org/) ## Demo diff --git a/config/webpack.common.js b/config/webpack.common.js index 07066bc..bf27df6 100644 --- a/config/webpack.common.js +++ b/config/webpack.common.js @@ -30,6 +30,7 @@ module.exports = { }, }, 'css-loader', + 'postcss-loader', ], }, { diff --git a/eslint.config.mjs b/eslint.config.mjs index e2ce8d0..1dbe093 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,6 +38,15 @@ export default [ 'react/jsx-uses-react': 'error', 'react/jsx-uses-vars': 'error', 'react/prop-types': 'off', + '@typescript-eslint/consistent-type-definitions': ['error', 'type'], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, ]; diff --git a/images/js-playground.png b/images/js-playground.png new file mode 100644 index 0000000..9fa2104 Binary files /dev/null and b/images/js-playground.png differ diff --git a/package-lock.json b/package-lock.json index eb4d5c3..08c0d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,13 @@ "version": "4.0.0", "license": "MIT", "dependencies": { + "@headlessui/react": "^2.2.2", + "@heroicons/react": "^2.2.0", "@testing-library/jest-dom": "^6.6.3", - "@uiw/react-json-view": "^2.0.0-alpha.30", "axios": "^1.7.9", "date-fns": "^4.1.0", "eslint-plugin-react": "^7.37.2", "gh-pages": "^6.2.0", - "github-fork-ribbon-css": "^0.2.3", "lodash": "^4.17.21", "luxon": "^3.5.0", "lz-string": "^1.5.0", @@ -24,6 +24,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-redux": "^9.2.0", + "react-split": "^2.0.14", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "typescript": "^5.7.2", @@ -32,6 +33,7 @@ "devDependencies": { "@eslint/js": "^9.16.0", "@semantic-release/git": "^10.0.1", + "@tailwindcss/postcss": "^4.1.5", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.14", @@ -55,8 +57,11 @@ "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.11", "mini-css-extract-plugin": "^2.9.2", + "postcss": "^8.5.3", + "postcss-loader": "^8.1.1", "prettier": "^3.4.2", "semantic-release": "^24.2.0", + "tailwindcss": "^4.1.5", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "typescript-eslint": "^8.18.0", @@ -71,6 +76,18 @@ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==" }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -607,6 +624,7 @@ "version": "7.24.8", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz", "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==", + "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -806,6 +824,81 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", + "integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz", + "integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==", + "dependencies": { + "@floating-ui/core": "^1.7.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + }, + "node_modules/@headlessui/react": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.2.tgz", + "integrity": "sha512-zbniWOYBQ8GHSUIOPY7BbdIn6PzUOq0z41RFrF30HbjsxG6Rrfk+6QulR8Kgf2Vwj2a/rE6i62q5vo+2gI5dJA==", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.17.1", + "@react-aria/interactions": "^3.21.3", + "@tanstack/react-virtual": "^3.13.6", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1609,6 +1702,96 @@ "node": ">=12" } }, + "node_modules/@react-aria/focus": { + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.2.tgz", + "integrity": "sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q==", + "dependencies": { + "@react-aria/interactions": "^3.25.0", + "@react-aria/utils": "^3.28.2", + "@react-types/shared": "^3.29.0", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.0.tgz", + "integrity": "sha512-GgIsDLlO8rDU/nFn6DfsbP9rfnzhm8QFjZkB9K9+r+MTSCn7bMntiWQgMM+5O6BiA8d7C7x4zuN4bZtc0RBdXQ==", + "dependencies": { + "@react-aria/ssr": "^3.9.8", + "@react-aria/utils": "^3.28.2", + "@react-stately/flags": "^3.1.1", + "@react-types/shared": "^3.29.0", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz", + "integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/utils": { + "version": "3.28.2", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.28.2.tgz", + "integrity": "sha512-J8CcLbvnQgiBn54eeEvQQbIOfBF3A1QizxMw9P4cl9MkeR03ug7RnjTIdJY/n2p7t59kLeAB3tqiczhcj+Oi5w==", + "dependencies": { + "@react-aria/ssr": "^3.9.8", + "@react-stately/flags": "^3.1.1", + "@react-stately/utils": "^3.10.6", + "@react-types/shared": "^3.29.0", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.1.tgz", + "integrity": "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg==", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.10.6", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.6.tgz", + "integrity": "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA==", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.29.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.29.0.tgz", + "integrity": "sha512-IDQYu/AHgZimObzCFdNl1LpZvQW/xcfLt3v20sorl5qRucDVj4S9os98sVTZ4IRIBjmS+MkjqpR5E70xan7ooA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -2168,6 +2351,292 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.5.tgz", + "integrity": "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg==", + "dev": true, + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.29.2", + "tailwindcss": "4.1.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.5.tgz", + "integrity": "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA==", + "dev": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.5", + "@tailwindcss/oxide-darwin-arm64": "4.1.5", + "@tailwindcss/oxide-darwin-x64": "4.1.5", + "@tailwindcss/oxide-freebsd-x64": "4.1.5", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.5", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.5", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.5", + "@tailwindcss/oxide-linux-x64-musl": "4.1.5", + "@tailwindcss/oxide-wasm32-wasi": "4.1.5", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.5", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.5" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.5.tgz", + "integrity": "sha512-LVvM0GirXHED02j7hSECm8l9GGJ1RfgpWCW+DRn5TvSaxVsv28gRtoL4aWKGnXqwvI3zu1GABeDNDVZeDPOQrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.5.tgz", + "integrity": "sha512-//TfCA3pNrgnw4rRJOqavW7XUk8gsg9ddi8cwcsWXp99tzdBAZW0WXrD8wDyNbqjW316Pk2hiN/NJx/KWHl8oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.5.tgz", + "integrity": "sha512-XQorp3Q6/WzRd9OalgHgaqgEbjP3qjHrlSUb5k1EuS1Z9NE9+BbzSORraO+ecW432cbCN7RVGGL/lSnHxcd+7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.5.tgz", + "integrity": "sha512-bPrLWbxo8gAo97ZmrCbOdtlz/Dkuy8NK97aFbVpkJ2nJ2Jo/rsCbu0TlGx8joCuA3q6vMWTSn01JY46iwG+clg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.5.tgz", + "integrity": "sha512-1gtQJY9JzMAhgAfvd/ZaVOjh/Ju/nCoAsvOVJenWZfs05wb8zq+GOTnZALWGqKIYEtyNpCzvMk+ocGpxwdvaVg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.5.tgz", + "integrity": "sha512-dtlaHU2v7MtdxBXoqhxwsWjav7oim7Whc6S9wq/i/uUMTWAzq/gijq1InSgn2yTnh43kR+SFvcSyEF0GCNu1PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.5.tgz", + "integrity": "sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.5.tgz", + "integrity": "sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.5.tgz", + "integrity": "sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.5.tgz", + "integrity": "sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.9", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.5.tgz", + "integrity": "sha512-oDKncffWzaovJbkuR7/OTNFRJQVdiw/n8HnzaCItrNQUeQgjy7oUiYpsm9HUBgpmvmDpSSbGaCa2Evzvk3eFmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.5.tgz", + "integrity": "sha512-WiR4dtyrFdbb+ov0LK+7XsFOsG+0xs0PKZKkt41KDn9jYpO7baE3bXiudPVkTqUEwNfiglCygQHl2jklvSBi7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.5.tgz", + "integrity": "sha512-5lAC2/pzuyfhsFgk6I58HcNy6vPK3dV/PoPxSDuOTVbDvCddYHzHiJZZInGIY0venvzzfrTEUAXJFULAfFmObg==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.5", + "@tailwindcss/oxide": "4.1.5", + "postcss": "^8.4.41", + "tailwindcss": "4.1.5" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.8.tgz", + "integrity": "sha512-meS2AanUg50f3FBSNoAdBSRAh8uS0ue01qm7zrw65KGJtiXB9QXfybqZwkh4uFpRv2iX/eu5tjcH5wqUpwYLPg==", + "dependencies": { + "@tanstack/virtual-core": "3.13.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.8.tgz", + "integrity": "sha512-BT6w89Hqy7YKaWewYzmecXQzcJh6HTBbKYJIIkMaNU49DZ06LoTV3z32DWWEdUsgW6n1xTmwTLs4GtWrZC261w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -2924,19 +3393,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@uiw/react-json-view": { - "version": "2.0.0-alpha.30", - "resolved": "https://registry.npmjs.org/@uiw/react-json-view/-/react-json-view-2.0.0-alpha.30.tgz", - "integrity": "sha512-ufvvirUQcITU9s4R12b7hn/t7ngLCYp1KbBxE+eAD35o3Ey+uxfKvgWmIwGFhV3hFXXxMJ8SHQKwl/ywNCHsDA==", - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.10.0", - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -4368,6 +4824,14 @@ "node": ">=6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5078,6 +5542,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -5331,9 +5804,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -6778,11 +7251,6 @@ "traverse": "0.6.8" } }, - "node_modules/github-fork-ribbon-css": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/github-fork-ribbon-css/-/github-fork-ribbon-css-0.2.3.tgz", - "integrity": "sha512-cmGBV4sivRwmnteSOkqMjN2cnP5/J1SU5aDCVYsBWHmDokZ/JjwGEkduCxY9IULHdCPpw1WSk5Cy8N1LF6jOEw==" - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8997,6 +9465,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "devOptional": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9189,6 +9666,234 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -13407,9 +14112,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -13426,14 +14131,80 @@ } ], "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-loader": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", + "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", + "dev": true, + "dependencies": { + "cosmiconfig": "^9.0.0", + "jiti": "^1.20.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/postcss-loader/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss-loader/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", @@ -13838,6 +14609,18 @@ } } }, + "node_modules/react-split": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.14.tgz", + "integrity": "sha512-bKWydgMgaKTg/2JGQnaJPg51T6dmumTWZppFgEbbY0Fbme0F5TuatAScCLaqommbGQQf/ZT1zaejuPDriscISA==", + "dependencies": { + "prop-types": "^15.5.7", + "split.js": "^1.6.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", @@ -14009,7 +14792,8 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", @@ -15120,9 +15904,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -15220,6 +16004,11 @@ "node": ">= 6" } }, + "node_modules/split.js": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/split.js/-/split.js-1.6.5.tgz", + "integrity": "sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw==" + }, "node_modules/split2": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", @@ -15571,6 +16360,17 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, + "node_modules/tailwindcss": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.5.tgz", + "integrity": "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==", + "dev": true + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -16007,10 +16807,9 @@ } }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/type-check": { "version": "0.4.0", @@ -16297,9 +17096,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", - "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } diff --git a/package.json b/package.json index cd70d4b..5522266 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,13 @@ ] }, "dependencies": { + "@headlessui/react": "^2.2.2", + "@heroicons/react": "^2.2.0", "@testing-library/jest-dom": "^6.6.3", - "@uiw/react-json-view": "^2.0.0-alpha.30", "axios": "^1.7.9", "date-fns": "^4.1.0", "eslint-plugin-react": "^7.37.2", "gh-pages": "^6.2.0", - "github-fork-ribbon-css": "^0.2.3", "lodash": "^4.17.21", "luxon": "^3.5.0", "lz-string": "^1.5.0", @@ -56,6 +56,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-redux": "^9.2.0", + "react-split": "^2.0.14", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "typescript": "^5.7.2", @@ -64,6 +65,7 @@ "devDependencies": { "@eslint/js": "^9.16.0", "@semantic-release/git": "^10.0.1", + "@tailwindcss/postcss": "^4.1.5", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.14", @@ -87,8 +89,11 @@ "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.2.11", "mini-css-extract-plugin": "^2.9.2", + "postcss": "^8.5.3", + "postcss-loader": "^8.1.1", "prettier": "^3.4.2", "semantic-release": "^24.2.0", + "tailwindcss": "^4.1.5", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "typescript-eslint": "^8.18.0", diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/public/index.html b/public/index.html index 6e48e5e..6fc5106 100644 --- a/public/index.html +++ b/public/index.html @@ -1,31 +1,18 @@ - - + + + - - - - - - + + JS Playground | A.B.O.L.K.O.G - - Fork me on GitHub - +
diff --git a/src/components/About/About.spec.tsx b/src/components/About/About.test.tsx similarity index 54% rename from src/components/About/About.spec.tsx rename to src/components/About/About.test.tsx index 587bccd..87ff4c4 100644 --- a/src/components/About/About.spec.tsx +++ b/src/components/About/About.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, render, screen, fireEvent } from '@testing-library/react'; import { LIBRARIES } from 'helpers/const'; import About from 'components/About'; import { AppContext } from 'context/AppContext'; @@ -6,16 +6,18 @@ import { AppActions } from 'context/Reducer'; describe('', () => { const state = { - display: 'block', + aboutModalOpen: true, } as AppState; const dispatch = jest.fn(); - beforeEach(() => { - render( - - - - ); + beforeEach(async () => { + await act(async () => { + render( + + + , + ); + }); }); it('render libraries list', () => { @@ -23,12 +25,10 @@ describe('', () => { expect(listElement.children.length).toEqual(LIBRARIES.length); }); - it('calls dispatch on button click', () => { - fireEvent.click(screen.getByTestId('modal-close-btn')); - + it('closes the modal', () => { + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }); expect(dispatch).toHaveBeenCalledWith({ - type: AppActions.TOGGLE_ABOUT_MODAL, - payload: 'none', + type: AppActions.HIDE_ABOUT_MODAL, }); }); }); diff --git a/src/components/About/About.tsx b/src/components/About/About.tsx index e5b5965..56e0b5a 100644 --- a/src/components/About/About.tsx +++ b/src/components/About/About.tsx @@ -2,53 +2,107 @@ import { useContext } from 'react'; import { LIBRARIES } from 'helpers/const'; import { AppContext } from 'context/AppContext'; import { AppActions } from 'context/Reducer'; -import Modal from 'components/Modal'; +import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; +import Title from 'components/Layout/Title'; const About: React.FC = () => { const { state, dispatch } = useContext(AppContext); - const open = state.display !== 'none'; - - const handleClose = () => { - dispatch({ type: AppActions.TOGGLE_ABOUT_MODAL, payload: 'none' }); + const closeModal = () => { + dispatch({ + type: AppActions.HIDE_ABOUT_MODAL, + }); }; - return ( - -

- JS Playground is an experimental JavaScript PlayGround created for - Education and Testing Purposes -

-
- This sandbox playground is hooked up directly with -
    - {LIBRARIES.map(lib => ( -
  • -
    - - {lib.name} v{lib.version} - - Use as {lib.use} + + + +
    +
    + +
    +
    + + + <div className="mt-2 flex flex-1 flex-col gap-3"> + <p> + JS Playground is an experimental JavaScript PlayGround + created for Education and Testing Purposes + </p> + <p>This sandbox playground is hooked up directly with</p> + + <ul + data-testid="about-libraries-list" + className="flex flex-1 flex-col gap-3" + > + {LIBRARIES.map(lib => ( + <li key={lib.name}> + <div className="flex justify-between"> + <a + href={lib.url} + target="_blank" + rel="noopener noreferrer" + className="underline hover:text-yellow-500 font-semibold" + > + {lib.name}{' '} + <span className="text-sm">v{lib.version}</span> + </a> + <span> + Use as{' '} + <span className="font-semibold text-yellow-500"> + {lib.use} + </span> + </span> + </div> + </li> + ))} + </ul> + + <p className="mt-4">Enjoy</p> + + <a + href="https://nyala.dev" + target="_blank" + rel="noopener noreferrer" + className="text-yellow-500 font-semibold underline hover:text-yellow-300" + > + Khalid Elshafie + </a> + <div className="mt-6 flex gap-4 justify-center"> + <a + href="https://github.com/abolkog/js-playground/fork" + target="_blank" + rel="noopener noreferrer" + className="bg-gray-800 hover:bg-gray-700 text-white font-semibold py-2 px-4 rounded border border-gray-700 transition" + > + Fork on GitHub + </a> + <a + href="https://github.com/abolkog/js-playground" + target="_blank" + rel="noopener noreferrer" + className="bg-yellow-500 hover:bg-yellow-400 text-black font-semibold py-2 px-4 rounded transition" + > + Star on GitHub + </a> + </div> + </div> </div> - </li> - ))} - </ul> - </div> - <p>Enjoy</p> - <div> - <div className="float-left"> - <a href="https://nyala.dev" target="_blank" rel="noopener noreferrer"> - Khalid Elshafie - </a> + </div> + </DialogPanel> </div> </div> - </Modal> + </Dialog> ); }; diff --git a/src/components/ActionButton/ActionButton.spec.tsx b/src/components/ActionButton/ActionButton.spec.tsx deleted file mode 100644 index 542f9ce..0000000 --- a/src/components/ActionButton/ActionButton.spec.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import ActionButton from 'components/ActionButton'; - -const buttonTestId = 'actionbutton-button'; -const iconTestId = 'actionbutton-icon'; -describe('<ActionButton />', () => { - const onClick = jest.fn(); - - it('render clear button when button type is clear', () => { - render(<ActionButton onClick={onClick} type="clear" />); - const button = screen.getByTestId(`${buttonTestId}-clear`); - const icon = screen.getByTestId(iconTestId); - - expect(button.textContent?.trim()).toEqual('Clear result'); - expect(button).toHaveClass('btn-info'); - expect(icon).toHaveClass('fa-trash'); - }); - - it('render execute button when button type is execute', () => { - render(<ActionButton onClick={onClick} type="execute" />); - const button = screen.getByTestId(`${buttonTestId}-execute`); - const icon = screen.getByTestId(iconTestId); - - expect(button.textContent?.trim()).toEqual('Run'); - expect(button).toHaveClass('btn-success'); - expect(icon).toHaveClass('fa-play-circle'); - }); - - it('render loading icon when loading prop is truthy', () => { - render(<ActionButton onClick={onClick} type="execute" loading />); - const icon = screen.getByTestId(iconTestId); - expect(icon).toHaveClass('fas fa-spinner fa-spin'); - }); - - it('invoke onClick when button is clicked', () => { - render(<ActionButton onClick={onClick} type="execute" />); - const button = screen.getByTestId(`${buttonTestId}-execute`); - fireEvent.click(button); - expect(onClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/ActionButton/ActionButton.tsx b/src/components/ActionButton/ActionButton.tsx deleted file mode 100644 index 6bfdc88..0000000 --- a/src/components/ActionButton/ActionButton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -const ButtonProps: Record<ActionButtonType, ActionButtonTypeProps> = { - clear: { - title: 'Clear result', - icon: 'fas fa-trash', - className: 'btn btn-info', - toolTip: 'Clear result (CtrCmd + l)', - }, - execute: { - title: 'Run', - icon: 'fas fa-play-circle', - className: 'btn btn-success', - toolTip: 'Run Code (CtrCmd + k)', - }, - history: { - title: 'History', - icon: 'fas fa-history', - className: 'btn btn-warning', - toolTip: 'Show run history', - }, -}; - -const ActionButton: React.FC<ActionButtonProps> = ({ - type, - onClick, - loading = false, -}) => { - const buttonProps = ButtonProps[type]; - const iconName = loading ? 'fas fa-spinner fa-spin' : buttonProps.icon; - return ( - <button - data-toggle="tooltip" - data-testid={`actionbutton-button-${type}`} - type="button" - onClick={onClick} - className={buttonProps.className} - disabled={loading} - title={buttonProps.toolTip} - > - <span>{buttonProps.title}</span> -   - <i data-testid="actionbutton-icon" className={iconName} /> - </button> - ); -}; - -export default ActionButton; diff --git a/src/components/ActionButton/index.tsx b/src/components/ActionButton/index.tsx deleted file mode 100644 index 9b18f0e..0000000 --- a/src/components/ActionButton/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'components/ActionButton/ActionButton'; diff --git a/src/components/ActionButton/types.d.ts b/src/components/ActionButton/types.d.ts deleted file mode 100644 index 62b5fab..0000000 --- a/src/components/ActionButton/types.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -type ActionButtonType = 'execute' | 'clear' | 'history'; - -interface ActionButtonProps { - type: ActionButtonType; - onClick: VoidFunction; - loading?: boolean; -} - -interface ActionButtonTypeProps { - title: string; - icon: string; - className: string; - toolTip?: string; -} diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index 07147a4..bd32cba 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -1,17 +1,16 @@ -import { useContext, useEffect, useState } from 'react'; -import Header from 'components/Header'; -import About from 'components/About'; +import Split from 'react-split'; +import Editor from '../CodeEditor/CodeEditor'; +import Console from 'components/Console'; +import { useContext, useEffect } from 'react'; import { AppContext } from 'context/AppContext'; import { AppActions } from 'context/Reducer'; -import ContextMenu from 'components/ContextMenu'; -import JsonView from 'components/JsonView'; -import CodeEditor from 'components/CodeEditor'; -import Console from 'components/Console'; -import HistoryModal from 'components/HistoryModal'; +import History from 'components/History'; +import About from 'components/About'; +import ShareCode from 'components/ShareCode'; +import { decompressFromEncodedURIComponent } from 'lz-string'; const App: React.FC = () => { const { dispatch } = useContext(AppContext); - const [position, setPosition] = useState<MenuPosition | null>(null); useEffect(() => { const consoleProxy = console.log; @@ -19,42 +18,43 @@ const App: React.FC = () => { dispatch({ type: AppActions.CODE_RUN_SUCCESS, payload: msg }); consoleProxy(msg); }; - }, []); - const handleContextMenu = (event: React.MouseEvent<HTMLDivElement>) => { - event.preventDefault(); - const { pageX, pageY } = event; - setPosition({ top: pageY, left: pageX }); - }; + try { + const params = new URLSearchParams(window.location.search); + const codeParam = params.get('code'); + if (codeParam) { + const payload = { + codeSample: decompressFromEncodedURIComponent(codeParam), + codeSampleName: 'URL Code', + }; + dispatch({ type: AppActions.LOAD_CODE_SAMPLE, payload }); + } + } catch (_) { + // Ignore errors in URL parsing or decompression + } + }, []); return ( - <div className="flex flexColumn"> - <Header /> - - <div className="flex flexColumn"> - <div className="editorContainer"> - <CodeEditor /> + <div className="flex flex-col h-screen font-sans scheme-light"> + <Split + className="flex flex-1 flex-col overflow-hidden" + sizes={[75, 25]} + minSize={0} + expandToMin={true} + dragInterval={1} + direction="vertical" + cursor="row-resize" + > + <div className="bg-[#1e1e1e] border-r border-zinc-700 overflow-hidden"> + <Editor /> </div> - <div className=" consoleContainer" onContextMenu={handleContextMenu}> - <Console /> - </div> - </div> + <Console /> + </Split> + <History /> <About /> - - <JsonView /> - - <ContextMenu - position={position} - onClose={() => setPosition(null)} - onClick={() => { - dispatch({ type: AppActions.TOGGLE_JSON_VIEW, payload: 'block' }); - setPosition(null); - }} - /> - - <HistoryModal /> + <ShareCode /> </div> ); }; diff --git a/src/components/Clickable/Clickable.spec.tsx b/src/components/Clickable/Clickable.spec.tsx deleted file mode 100644 index 7e4827a..0000000 --- a/src/components/Clickable/Clickable.spec.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import Clickable from 'components/Clickable'; - -describe('<Clickable />', () => { - it('invoke on click when button clicked', () => { - const onClick = jest.fn(); - render(<Clickable onClick={onClick} />); - fireEvent.click(screen.getByTestId('app-clickable')); - expect(onClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/Clickable/Clickable.tsx b/src/components/Clickable/Clickable.tsx deleted file mode 100644 index d8dca23..0000000 --- a/src/components/Clickable/Clickable.tsx +++ /dev/null @@ -1,13 +0,0 @@ -const Clickable: React.FC<ClickableProps> = ({ onClick, children }) => ( - <button - data-testid="app-clickable" - className="clickable" - type="button" - onClick={onClick} - aria-label="clickable" - > - {children} - </button> -); - -export default Clickable; diff --git a/src/components/Clickable/index.tsx b/src/components/Clickable/index.tsx deleted file mode 100644 index 91fe454..0000000 --- a/src/components/Clickable/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'components/Clickable/Clickable'; diff --git a/src/components/Clickable/types.d.ts b/src/components/Clickable/types.d.ts deleted file mode 100644 index 5d3fb5d..0000000 --- a/src/components/Clickable/types.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -interface ClickableProps extends React.HTMLAttributes<HTMLButtonElement> { - onClick: VoidFunction; -} diff --git a/src/components/CodeEditor/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index da27898..b7a0596 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -17,7 +17,7 @@ const CodeEditor: React.FC = () => { value: '', language: 'typescript', fontSize: 20, - theme: state.theme, + theme: 'vs-dark', minimap: { enabled: false, }, @@ -25,7 +25,7 @@ const CodeEditor: React.FC = () => { const editorInstance = monaco.editor.create( editorRef.current!, - editorConfig + editorConfig, ); monaco.editor.setModelLanguage(editorInstance.getModel()!, 'typescript'); @@ -69,10 +69,6 @@ const CodeEditor: React.FC = () => { return () => editor?.dispose(); }, [editorRef.current]); - useEffect(() => { - editor?.updateOptions({ theme: state.theme }); - }, [state.theme]); - useEffect(() => { editor?.setValue(state.codeSample); }, [state.codeSample]); diff --git a/src/components/Console/Console.spec.tsx b/src/components/Console/Console.test.tsx similarity index 65% rename from src/components/Console/Console.spec.tsx rename to src/components/Console/Console.test.tsx index 094ee4a..d0019ff 100644 --- a/src/components/Console/Console.spec.tsx +++ b/src/components/Console/Console.test.tsx @@ -8,7 +8,6 @@ describe('<Console />', () => { afterEach(() => { state = { - display: 'block', error: '', result: [''], } as AppState; @@ -20,7 +19,7 @@ describe('<Console />', () => { render( <AppContext.Provider value={{ state, dispatch }}> <Console /> - </AppContext.Provider> + </AppContext.Provider>, ); const errorDiv = screen.getByTestId('console-error'); @@ -34,12 +33,12 @@ describe('<Console />', () => { render( <AppContext.Provider value={{ state, dispatch }}> <Console /> - </AppContext.Provider> + </AppContext.Provider>, ); expect(screen.queryByTestId('console-error')).not.toBeInTheDocument(); expect(screen.getByTestId('console-result').children.length).toEqual( - result.length + result.length, ); }); @@ -49,14 +48,33 @@ describe('<Console />', () => { render( <AppContext.Provider value={{ state, dispatch }}> <Console /> - </AppContext.Provider> + </AppContext.Provider>, ); expect(screen.getByTestId('console-result-item-0').textContent).toEqual( - JSON.stringify(result[0]) + JSON.stringify(result[0], null, 0), ); expect(screen.getByTestId('console-result-item-1').textContent).toEqual( - result[1] + JSON.stringify(result[1], null, 0), + ); + }); + + it('add indentation when result is large', () => { + const largeObj = { + a: 'This is a long string to make the object large enough for pretty print.', + b: Array(10).fill('more data'), + c: { nested: true, arr: [1, 2, 3, 4, 5] }, + }; + state.result = [largeObj]; + render( + <AppContext.Provider value={{ state, dispatch }}> + <Console /> + </AppContext.Provider>, + ); + + // The component should pretty-print with 2 spaces for large objects + expect(screen.getByTestId('console-result-item-0').textContent).toEqual( + JSON.stringify(largeObj, null, 3), ); }); }); diff --git a/src/components/Console/Console.tsx b/src/components/Console/Console.tsx index d66a4e5..5c61191 100644 --- a/src/components/Console/Console.tsx +++ b/src/components/Console/Console.tsx @@ -1,41 +1,48 @@ import { useContext } from 'react'; -import _ from 'lodash'; import { AppContext } from 'context/AppContext'; const Console: React.FC = () => { const { state } = useContext(AppContext); - const { result, error, theme } = state; - const extraClass = theme === 'vs-light' ? 'console-light' : ''; + const { result, error } = state; const createKey = (index: number) => { return `key${index}`; }; - if (error) { + const renderError = () => { return ( - <div className="console"> - <div data-testid="console-error" className="error"> - {error} - </div> + <div data-testid="console-error" className="font-bold text-red-500"> + {error} </div> ); - } + }; + + const renderResult = () => { + return result.map((item, index) => { + const key = createKey(index); + const isObject = typeof item === 'object' && item !== null; + const str = JSON.stringify(item); + const space = isObject && str.length > 60 ? 3 : 0; + + return ( + <div key={key} className="py-1.5"> + <pre> + <span className="mr-2 text-[#8be9fd]">›</span> + <span data-testid={`console-result-item-${index}`}> + {JSON.stringify(item, null, space)} + </span> + </pre> + </div> + ); + }); + }; return ( - <div data-testid="console-result" className={`console ${extraClass}`}> - {result.map((item, index) => { - const consoleItem = !_.isString(item) ? JSON.stringify(item) : item; - return ( - <div key={createKey(index)}> - <pre> - <span style={{ marginRight: 5 }}>›</span> - <span data-testid={`console-result-item-${index}`}> - {consoleItem} - </span> - </pre> - </div> - ); - })} + <div + data-testid="console-result" + className="bg-[#282a36] text-[#f8f8f2] font-mono overflow-x-auto p-4" + > + {error ? renderError() : renderResult()} </div> ); }; diff --git a/src/components/ContextMenu/ContextMenu.spec.tsx b/src/components/ContextMenu/ContextMenu.spec.tsx deleted file mode 100644 index 06f1e4e..0000000 --- a/src/components/ContextMenu/ContextMenu.spec.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import ContextMenu from 'components/ContextMenu'; - -describe('<ContextMenu/>', () => { - const props: ContextMenuProps = { - position: { left: 1, top: 1 }, - onClick: jest.fn(), - onClose: jest.fn(), - }; - it('does not render if position is not defined', () => { - render(<ContextMenu {...props} position={null} />); - expect(screen.queryByTestId('app-context-menu')).not.toBeInTheDocument(); - }); - - it('render context menu when position is defined', () => { - render(<ContextMenu {...props} />); - const menuItem = screen.queryByTestId('app-context-menu'); - expect(menuItem).toBeInTheDocument(); - }); -}); diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx deleted file mode 100644 index e87111b..0000000 --- a/src/components/ContextMenu/ContextMenu.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import Clickable from 'components/Clickable'; - -const ContextMenu: React.FC<ContextMenuProps> = ({ - position, - onClose, - onClick, -}) => { - if (!position) return null; - - return ( - <ul - data-testid="app-context-menu" - className="menu" - style={{ - top: `${position.top}px`, - left: `${position.left}px`, - }} - > - <li> - <Clickable onClick={onClick}>View JSON</Clickable> - </li> - <li> - <Clickable onClick={onClose}>Close</Clickable> - </li> - </ul> - ); -}; - -export default ContextMenu; diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx deleted file mode 100644 index 9cb8a35..0000000 --- a/src/components/ContextMenu/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'components/ContextMenu/ContextMenu'; diff --git a/src/components/ContextMenu/types.d.ts b/src/components/ContextMenu/types.d.ts deleted file mode 100644 index 7b32c36..0000000 --- a/src/components/ContextMenu/types.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -interface MenuPosition { - top: number; - left: number; -} -interface ContextMenuProps { - position: MenuPosition | null; - onClose: VoidFunction; - onClick: VoidFunction; -} diff --git a/src/components/Header/Header.spec.tsx b/src/components/Header/Header.spec.tsx deleted file mode 100644 index a61c6b0..0000000 --- a/src/components/Header/Header.spec.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import Header from 'components/Header'; -import { AppContext } from 'context/AppContext'; -import { AppActions } from 'context/Reducer'; - -describe('<Header />', () => { - describe('header actions', () => { - const state = { codeSample: '', codeSampleName: '' } as AppState; - let dispatch: jest.Mock; - beforeEach(() => { - dispatch = jest.fn(); - render( - <AppContext.Provider value={{ state, dispatch }}> - <Header /> - </AppContext.Provider>, - ); - }); - - afterEach(() => { - cleanup(); - }); - - it('dispatch about action when about button is click', () => { - fireEvent.click(screen.getByText('About')); - expect(dispatch).toHaveBeenCalledWith({ - payload: 'block', - type: AppActions.TOGGLE_ABOUT_MODAL, - }); - }); - it('toggle the theme when toggle button is click', () => { - fireEvent.click(screen.getByTestId('app-theme-button-vs-dark')); - expect(dispatch).toHaveBeenCalledWith({ - payload: 'vs-dark', - type: AppActions.TOGGLE_THEME, - }); - }); - - it('dispatch execute code action when run button is clicked', () => { - fireEvent.click(screen.getByTestId('actionbutton-button-execute')); - expect(dispatch).toHaveBeenCalledWith({ - type: AppActions.CODE_RUNNING, - }); - }); - - it('dispatch clear result action when clear button is clicked', () => { - fireEvent.click(screen.getByTestId('actionbutton-button-clear')); - expect(dispatch).toHaveBeenCalledWith({ - type: AppActions.CLEAR_RESULT, - }); - }); - - it('dispatch load code sample on sample change', () => { - fireEvent.change(screen.getByTestId('header-code-selector'), { - target: { value: 'Axios' }, - }); - expect(dispatch).toHaveBeenCalledWith({ - type: AppActions.LOAD_CODE_SAMPLE, - payload: expect.objectContaining({ - codeSampleName: 'Axios', - }), - }); - }); - }); -}); diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx deleted file mode 100644 index 10e64ba..0000000 --- a/src/components/Header/Header.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { ChangeEvent, useContext } from 'react'; -import ActionButton from 'components/ActionButton'; -import { AppContext } from 'context/AppContext'; -import { AppActions } from 'context/Reducer'; -import { CODE_SAMPLES, EDITOR_THEMES } from 'helpers/const'; -import useCodeRunner from 'hooks/useCodeRunner'; - -const Header: React.FC = () => { - const { state, dispatch } = useContext(AppContext); - const { runCode } = useCodeRunner(); - const { theme, codeSampleName } = state; - - const handleChange = (e: ChangeEvent<HTMLSelectElement>) => { - const { - target: { value }, - } = e; - - const { codeSample } = CODE_SAMPLES.find(item => item.name === value)!; - - const payload = { - codeSample, - codeSampleName: value, - }; - - dispatch({ type: AppActions.LOAD_CODE_SAMPLE, payload }); - }; - - return ( - <nav className="navbar navbar-expand-lg navbar-dark bg-primary px-2"> - <a className="navbar-brand" href="/"> - JS PlayGround - </a> - - <div className="collapse navbar-collapse"> - <ul className="navbar-nav me-auto"> - <li className="nav-item"> - <button - type="button" - className="btn btn-secondary" - style={{ cursor: 'pointer' }} - onClick={() => - dispatch({ - type: AppActions.TOGGLE_ABOUT_MODAL, - payload: 'block', - }) - } - > - About - </button> - </li> - </ul> - - <div className="my-2 app-actions"> - <div> - <select - data-testid="header-code-selector" - className="form-control" - value={codeSampleName} - onChange={handleChange} - > - <option value="" disabled> - Load Sample Code - </option> - {CODE_SAMPLES.map(item => ( - <option value={item.name} key={item.id}> - {item.name} - </option> - ))} - </select> - </div> - <span style={{ marginLeft: 20, marginRight: 20 }} /> - - <div className="btn-group" role="group"> - {EDITOR_THEMES.map(item => ( - <button - key={item.id} - type="button" - data-testid={`app-theme-button-${item.value}`} - className={`btn btn-${ - theme === item.value ? 'warning' : ' default' - }`} - onClick={() => { - dispatch({ - type: AppActions.TOGGLE_THEME, - payload: item.value, - }); - }} - > - <i className={`fas fa-${item.icon}`} /> - </button> - ))} - </div> - - <span style={{ marginLeft: 20, marginRight: 20 }} /> - <ActionButton - type="clear" - onClick={() => dispatch({ type: AppActions.CLEAR_RESULT })} - /> - - <span style={{ marginLeft: 20, marginRight: 20 }} /> - <ActionButton - type="history" - onClick={() => dispatch({ type: AppActions.TOGGLE_HISTORY_MODAL })} - /> - - <span style={{ marginLeft: 20, marginRight: 20 }} /> - <ActionButton - type="execute" - onClick={() => runCode(state.code)} - loading={state.loading} - /> - </div> - </div> - </nav> - ); -}; - -export default Header; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx deleted file mode 100644 index a0dab5b..0000000 --- a/src/components/Header/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'components/Header/Header'; diff --git a/src/components/History/History.test.tsx b/src/components/History/History.test.tsx new file mode 100644 index 0000000..85a9bd7 --- /dev/null +++ b/src/components/History/History.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { fireEvent, act, render, screen } from '@testing-library/react'; +import { AppContext } from 'context/AppContext'; +import { AppActions } from 'context/Reducer'; +import History from 'components/History'; +import * as StorageService from 'services/storage'; + +const getHistorySpy = jest.spyOn(StorageService, 'getHistory'); +const mockHistory = [ + { + date: 'second', + code: 'const a = 10', + }, + { + date: 'first', + code: 'const b = 20', + }, +]; + +describe('<History />', () => { + const state = { + historyOpen: true, + } as AppState; + const dispatch = jest.fn(); + + beforeEach(async () => { + getHistorySpy.mockReturnValue([...mockHistory]); + await act(async () => { + render( + <AppContext.Provider value={{ state, dispatch }}> + <History /> + </AppContext.Provider>, + ); + }); + }); + + afterEach(jest.clearAllMocks); + + it('render history list', () => { + const historyElement = screen.getByTestId('history-container'); + expect(historyElement.children.length).toEqual(2); + }); + + it('calls closes the modal on button click', () => { + const closeButton = screen.getByTestId('history-close-btn'); + fireEvent.click(closeButton); + + expect(dispatch).toHaveBeenCalledWith({ + type: AppActions.HIDE_HISTORY, + }); + }); + + it('restores history when restore button is clicked', async () => { + // Expand + const disclosureButtons = await screen.findAllByRole('button'); + for (const btn of disclosureButtons) { + if (btn.textContent !== 'Restore') { + await act(async () => { + fireEvent.click(btn); + }); + } + } + + const restoreButtons = await screen.findAllByRole('button', { + name: /Restore/, + }); + await act(async () => { + fireEvent.click(restoreButtons[0]); + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: AppActions.LOAD_CODE_SAMPLE, + payload: { + codeSample: mockHistory[1].code, + codeSampleName: '', + }, + }); + }); +}); diff --git a/src/components/History/History.tsx b/src/components/History/History.tsx new file mode 100644 index 0000000..9782fb5 --- /dev/null +++ b/src/components/History/History.tsx @@ -0,0 +1,121 @@ +import { useContext } from 'react'; +import { + Dialog, + DialogPanel, + DialogTitle, + Disclosure, + DisclosureButton, + DisclosurePanel, +} from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/20/solid'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { AppContext } from 'context/AppContext'; +import { AppActions } from 'context/Reducer'; +import { getHistory } from 'services/storage'; + +const History: React.FC = () => { + const { state, dispatch } = useContext(AppContext); + + const history = getHistory(); + + const makeKey = (item: HistoryItem, index: number) => `${item.date}_${index}`; + + const handleRestore = (item: HistoryItem) => () => { + const payload = { + codeSample: item.code, + codeSampleName: '', + }; + + dispatch({ type: AppActions.LOAD_CODE_SAMPLE, payload }); + }; + + return ( + <Dialog + open={state.historyOpen} + onClose={() => dispatch({ type: AppActions.HIDE_HISTORY })} + className="relative z-10" + > + <div className="fixed inset-0" /> + + <div className="fixed inset-0 overflow-hidden"> + <div className="absolute inset-0 overflow-hidden"> + <div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10 sm:pl-16"> + <DialogPanel + transition + className="pointer-events-auto w-screen max-w-2xl transform transition duration-500 ease-in-out data-closed:translate-x-full sm:duration-700" + > + <div className="flex h-full flex-col overflow-y-scroll bg-gray-900 text-white py-6 shadow-xl"> + <div className="px-4 sm:px-6"> + <div className="flex items-start justify-between"> + <DialogTitle className="text-base font-semibold text-white"> + Code History + </DialogTitle> + <div className="ml-3 flex h-7 items-center"> + <button + data-testid="history-close-btn" + type="button" + onClick={() => + dispatch({ type: AppActions.HIDE_HISTORY }) + } + className="relative rounded-md bg-gray-900 text-white hover:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-hidden cursor-pointer" + > + <span className="absolute -inset-2.5" /> + <span className="sr-only">Close</span> + <XMarkIcon aria-hidden="true" className="size-6" /> + </button> + </div> + </div> + </div> + <div className="relative mt-6 flex-1 px-4 sm:px-6"> + <div className="h-screen w-full"> + <div + className="w-full divide-y divide-white/5 rounded-xl bg-white/5" + data-testid="history-container" + > + {[...history].reverse().map((item, index) => { + return ( + <Disclosure + as="div" + key={makeKey(item, index)} + className="p-6" + > + <DisclosureButton className="group flex w-full items-center justify-between cursor-pointer"> + <span className="text-sm/6 font-medium text-white group-data-hover:text-white/80"> + ({item.date} #({index + 1})){' - '} + {item.code.slice(0, 50)}... + </span> + <ChevronDownIcon className="size-5 fill-white/60 group-data-hover:fill-white/50 group-data-open:rotate-180" /> + </DisclosureButton> + <DisclosurePanel className="mt-2 text-sm/5 text-white/50"> + <pre + className=" + bg-gray-800 text-green-200 rounded p-4 font-mono text-xs whitespace-pre-wrap break-words overflow-x-auto border border-gray-700 shadow-inner max-h-64" + > + {item.code} + </pre> + <div className="flex justify-end mt-4"> + <button + type="button" + className="bg-gray-900 text-green-200 rounded px-4 py-1 border border-gray-700 shadow hover:bg-gray-700 hover:text-green-100 focus:outline-none focus:ring-2 focus:ring-green-400 transition cursor-pointer" + onClick={handleRestore(item)} + > + Restore + </button> + </div> + </DisclosurePanel> + </Disclosure> + ); + })} + </div> + </div> + </div> + </div> + </DialogPanel> + </div> + </div> + </div> + </Dialog> + ); +}; + +export default History; diff --git a/src/components/History/index.tsx b/src/components/History/index.tsx new file mode 100644 index 0000000..43c8ad7 --- /dev/null +++ b/src/components/History/index.tsx @@ -0,0 +1 @@ +export { default } from 'components/History/History'; diff --git a/src/components/HistoryModal/HistoryModal.spec.tsx b/src/components/HistoryModal/HistoryModal.spec.tsx deleted file mode 100644 index 23b693e..0000000 --- a/src/components/HistoryModal/HistoryModal.spec.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { AppContext } from 'context/AppContext'; -import { AppActions } from 'context/Reducer'; -import HistoryModal from 'components/HistoryModal'; -import * as StorageService from 'services/storage'; - -const getHistorySpy = jest.spyOn(StorageService, 'getHistory'); -const mockHistory = [ - { - date: 'second', - code: 'const a = 10', - }, - { - date: 'first', - code: 'const b = 20', - }, -]; - -describe('<HistoryModal />', () => { - const state = { - historyModalShown: true, - } as AppState; - const mockSetState = jest.fn(); - const dispatch = jest.fn(); - - beforeEach(() => { - getHistorySpy.mockReturnValue(mockHistory); - jest.spyOn(React, 'useState').mockReturnValue([0, mockSetState]); - render( - <AppContext.Provider value={{ state, dispatch }}> - <HistoryModal /> - </AppContext.Provider> - ); - }); - - afterEach(jest.clearAllMocks); - - it('render history list', () => { - const historyElement = screen.getByTestId('history-accordion'); - expect(historyElement.children.length).toEqual(2); - }); - - it('calls closes the modal on button click', () => { - fireEvent.click(screen.getByTestId('modal-close-btn')); - - expect(dispatch).toHaveBeenCalledWith({ - type: AppActions.TOGGLE_HISTORY_MODAL, - }); - }); - - describe('when restoring history', () => { - const historyItemIndex = 0; - beforeEach(async () => { - const restoreButtons = await screen.findAllByRole('button', { - name: /Restore/, - }); - fireEvent.click(restoreButtons[historyItemIndex]); - }); - - it('restore history when restore button click', () => { - expect(dispatch).toHaveBeenNthCalledWith(1, { - type: AppActions.LOAD_CODE_SAMPLE, - payload: { - codeSample: mockHistory[historyItemIndex].code, - codeSampleName: '', - }, - }); - }); - - it('dismiss the modal', () => { - expect(dispatch).toHaveBeenNthCalledWith(2, { - type: AppActions.TOGGLE_HISTORY_MODAL, - }); - }); - }); -}); diff --git a/src/components/HistoryModal/HistoryModal.tsx b/src/components/HistoryModal/HistoryModal.tsx deleted file mode 100644 index 7bf8393..0000000 --- a/src/components/HistoryModal/HistoryModal.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useContext, useState } from 'react'; -import { AppContext } from 'context/AppContext'; -import Modal from 'components/Modal'; -import { AppActions } from 'context/Reducer'; -import { getHistory } from 'services/storage'; - -const HistoryModal: React.FC = () => { - const { state, dispatch } = useContext(AppContext); - const [activeIndex, setActiveIndex] = useState(-1); - - const history = getHistory(); - - const makeKey = (item: HistoryItem, index: number) => `${item.date}_${index}`; - - const handleHeaderButtonClick = (index: number) => { - if (index === activeIndex) { - setActiveIndex(-1); - } else setActiveIndex(index); - }; - - const handleRestore = (item: HistoryItem) => () => { - const payload = { - codeSample: item.code, - codeSampleName: '', - }; - - dispatch({ type: AppActions.LOAD_CODE_SAMPLE, payload }); - dispatch({ type: AppActions.TOGGLE_HISTORY_MODAL }); - }; - - return ( - <Modal - isOpen={state.historyModalShown} - onClose={() => dispatch({ type: AppActions.TOGGLE_HISTORY_MODAL })} - title="Code History" - > - <div className="accordion" data-testid="history-accordion"> - {history.reverse().map((item, index) => { - const isActive = index === activeIndex; - return ( - <div className="accordion-item" key={makeKey(item, index)}> - <h2 className="accordion-header"> - <button - className={`accordion-button ${isActive ? '' : 'collapsed'}`} - type="button" - onClick={() => handleHeaderButtonClick(index)} - > - ({item.date} #({index + 1})){' - '} - {item.code.slice(0, 100)} - </button> - </h2> - <div - className={`accordion-collapse collapse ${ - isActive ? 'show' : '' - }`} - > - <div className="accordion-body"> - <pre>{item.code}</pre> - <div className="d-flex justify-content-end"> - <button - type="button" - className="btn btn-success" - onClick={handleRestore(item)} - > - Restore - </button> - </div> - </div> - </div> - </div> - ); - })} - </div> - </Modal> - ); -}; - -export default HistoryModal; diff --git a/src/components/HistoryModal/index.tsx b/src/components/HistoryModal/index.tsx deleted file mode 100644 index 7f283bf..0000000 --- a/src/components/HistoryModal/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'components/HistoryModal/HistoryModal'; diff --git a/src/components/JsonView/JsonView.spec.tsx b/src/components/JsonView/JsonView.spec.tsx deleted file mode 100644 index f21c19c..0000000 --- a/src/components/JsonView/JsonView.spec.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import JsonView from 'components/JsonView'; -import { AppContext } from 'context/AppContext'; -import { AppActions } from 'context/Reducer'; - -describe('<JsonView />', () => { - const state = { - display: 'block', - result: [''], - } as AppState; - const dispatch = jest.fn(); - - beforeEach(() => { - render( - <AppContext.Provider value={{ state, dispatch }}> - <JsonView /> - </AppContext.Provider>, - ); - }); - - afterEach(cleanup); - - it('calls dispatch on button click', () => { - fireEvent.click(screen.getByTestId('modal-close-btn')); - - expect(dispatch).toHaveBeenCalledWith({ - type: AppActions.TOGGLE_JSON_VIEW, - payload: 'none', - }); - }); -}); diff --git a/src/components/JsonView/JsonView.tsx b/src/components/JsonView/JsonView.tsx deleted file mode 100644 index d307b0a..0000000 --- a/src/components/JsonView/JsonView.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useContext } from 'react'; -import ReactJsonView from '@uiw/react-json-view'; - -import { githubLightTheme } from '@uiw/react-json-view/githubLight'; -import { nordTheme } from '@uiw/react-json-view/nord'; - -import { AppContext } from 'context/AppContext'; -import { AppActions } from 'context/Reducer'; -import Modal from 'components/Modal'; - -const JsonView: React.FC = () => { - const { state, dispatch } = useContext(AppContext); - const open = state.jsonView !== 'none'; - const theme = state.theme === 'vs-light' ? githubLightTheme : nordTheme; - - const handleClose = () => { - dispatch({ type: AppActions.TOGGLE_JSON_VIEW, payload: 'none' }); - }; - - return ( - <Modal isOpen={open} onClose={handleClose} title="JSON View"> - <ReactJsonView value={state.result.filter(item => item)} style={theme} /> - </Modal> - ); -}; - -export default JsonView; diff --git a/src/components/JsonView/index.tsx b/src/components/JsonView/index.tsx deleted file mode 100644 index 020d800..0000000 --- a/src/components/JsonView/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'components/JsonView/JsonView'; diff --git a/src/components/Layout/ActionBar.test.tsx b/src/components/Layout/ActionBar.test.tsx new file mode 100644 index 0000000..83fb245 --- /dev/null +++ b/src/components/Layout/ActionBar.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import ActionBar from './ActionBar'; +import { AppContext } from 'context/AppContext'; +import { AppActions } from 'context/Reducer'; + +describe('<ActionBar />', () => { + const dispatch = jest.fn(); + const state = { + code: 'console.log("hello")', + loading: false, + result: [], + error: '', + sidebarOpen: false, + historyOpen: false, + aboutModalOpen: false, + shareUrl: '', + codeSample: '', + codeSampleName: '', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + function renderComponent(customState = {}) { + render( + <AppContext.Provider + value={{ state: { ...state, ...customState }, dispatch }} + > + <ActionBar /> + </AppContext.Provider>, + ); + } + + it('renders all main action buttons', () => { + renderComponent(); + expect(screen.getByText('Run Code')).toBeInTheDocument(); + expect(screen.getByText('Clear Console')).toBeInTheDocument(); + expect(screen.getByText('History')).toBeInTheDocument(); + expect(screen.getByText('Code Samples')).toBeInTheDocument(); + expect(screen.getByText('Share Code')).toBeInTheDocument(); + expect(screen.getByText('About JS Playground')).toBeInTheDocument(); + }); + + it('calls runCode when "Run Code" is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('Run Code')); + expect(dispatch).toHaveBeenCalledWith({ + type: AppActions.CODE_RUNNING, + }); + }); + + it('dispatches CLEAR_RESULT when "Clear Console" is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('Clear Console')); + expect(dispatch).toHaveBeenCalledWith({ type: AppActions.CLEAR_RESULT }); + }); + + it('dispatches SHOW_HISTORY when "History" is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('History')); + expect(dispatch).toHaveBeenCalledWith({ type: AppActions.SHOW_HISTORY }); + }); + + it('dispatches SET_SHARE_URL when "Share Code" is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('Share Code')); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: AppActions.SET_SHARE_URL }), + ); + + const call = dispatch.mock.calls.find( + ([arg]) => arg.type === AppActions.SET_SHARE_URL, + ); + expect(call[0].payload).toContain('code='); + }); + + it('dispatches SHOW_ABOUT_MODAL when "About JS Playground" is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('About JS Playground')); + expect(dispatch).toHaveBeenCalledWith({ + type: AppActions.SHOW_ABOUT_MODAL, + }); + }); +}); diff --git a/src/components/Layout/ActionBar.tsx b/src/components/Layout/ActionBar.tsx new file mode 100644 index 0000000..85e247c --- /dev/null +++ b/src/components/Layout/ActionBar.tsx @@ -0,0 +1,162 @@ +import { useContext, useEffect, useState } from 'react'; +import { + Disclosure, + DisclosureButton, + DisclosurePanel, +} from '@headlessui/react'; +import { ChevronRightIcon } from '@heroicons/react/20/solid'; +import { + PlayIcon, + ShareIcon, + TrashIcon, + CodeBracketIcon, + ClockIcon, +} from '@heroicons/react/24/solid'; +import { compressToEncodedURIComponent } from 'lz-string'; +import Spinner from 'components/Spinner'; +import { AppContext } from 'context/AppContext'; +import { AppActions } from 'context/Reducer'; +import { CODE_SAMPLES, MAX_SHARE_CODE_LENGTH } from 'helpers/const'; +import useCodeRunner from 'hooks/useCodeRunner'; + +const codeSampleToMenu = CODE_SAMPLES.map(sample => { + const { codeSample, name } = sample; + const payload = { + codeSample, + codeSampleName: name, + }; + + return { + label: name, + payload: { type: AppActions.LOAD_CODE_SAMPLE, payload }, + }; +}); + +const actionBarItems: ActionBarItem[] = [ + { + label: 'Clear Console', + icon: TrashIcon, + payload: { type: AppActions.CLEAR_RESULT }, + }, + { + label: 'History', + icon: ClockIcon, + payload: { type: AppActions.SHOW_HISTORY }, + }, + { + label: 'Code Samples', + icon: CodeBracketIcon, + children: codeSampleToMenu, + }, +]; + +const ActionBar: React.FC = () => { + const { state, dispatch } = useContext(AppContext); + const { runCode } = useCodeRunner(); + const [showShareButton, setShowShareButton] = useState(false); + + useEffect(() => { + const { code } = state; + const showShareButton = code.length > 0; + setShowShareButton(showShareButton); + }, [state.code.length]); + + const onShareButtonClick = () => { + const compressedCode = compressToEncodedURIComponent(state.code); + const shareUrl = `${window.location.origin}/?code=${compressedCode}`; + if (shareUrl.length > MAX_SHARE_CODE_LENGTH) { + alert('The code is too long to share. Please reduce the code length.'); + return; + } + dispatch({ type: AppActions.SET_SHARE_URL, payload: shareUrl }); + }; + + return ( + <nav className="flex flex-1 flex-col"> + <ul role="list" className="flex flex-1 flex-col gap-y-7"> + <li> + <ul role="list" className="-mx-2 space-y-1"> + <li> + {state.loading ? ( + <span className="text-gray-400 hover:bg-gray-800 hover:text-white group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold cursor-pointer"> + Running Code ... + <Spinner /> + </span> + ) : ( + <a + onClick={() => runCode(state.code)} + className="text-gray-400 hover:bg-gray-800 hover:text-white group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold cursor-pointer" + > + Run Code + <PlayIcon aria-hidden="true" className="size-5 shrink-0" /> + </a> + )} + </li> + {actionBarItems.map(item => ( + <li key={item.label}> + {!item.children ? ( + <a + onClick={() => dispatch(item.payload!)} + className="text-gray-400 hover:bg-gray-800 hover:text-white group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold cursor-pointer" + > + {item.label} + <item.icon aria-hidden="true" className="size-5 shrink-0" /> + </a> + ) : ( + <Disclosure as="div"> + <DisclosureButton className="text-gray-400 hover:bg-gray-800 hover:text-white group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold"> + <ChevronRightIcon + aria-hidden="true" + className="size-5 shrink-0 text-gray-400 group-data-open:rotate-90 group-data-open:text-gray-500" + /> + {item.label} + <item.icon + aria-hidden="true" + className="size-5 shrink-0" + /> + </DisclosureButton> + <DisclosurePanel as="ul" className="mt-1 px-2"> + {item.children.map(subItem => ( + <li key={subItem.label}> + <DisclosureButton + as="a" + onClick={() => dispatch(subItem.payload!)} + className="text-gray-400 hover:bg-gray-800 hover:text-white group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold cursor-pointer" + > + {subItem.label} + </DisclosureButton> + </li> + ))} + </DisclosurePanel> + </Disclosure> + )} + </li> + ))} + {showShareButton && ( + <li> + <a + onClick={onShareButtonClick} + className="text-amber-400 hover:bg-gray-800 hover:amber-600 group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold cursor-pointer" + > + Share Code + <ShareIcon aria-hidden="true" className="size-5 shrink-0" /> + </a> + </li> + )} + </ul> + </li> + <li className="-mx-6 mt-auto"> + <a + onClick={() => dispatch({ type: AppActions.SHOW_ABOUT_MODAL })} + className="text-gray-400 hover:bg-gray-800 hover:text-white group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold cursor-pointer" + > + <span className="sr-only">About</span> + <span>About JS Playground</span> + </a> + </li> + </ul> + </nav> + ); +}; + +export default ActionBar; diff --git a/src/components/Layout/RootLayout.tsx b/src/components/Layout/RootLayout.tsx new file mode 100644 index 0000000..03c1b52 --- /dev/null +++ b/src/components/Layout/RootLayout.tsx @@ -0,0 +1,84 @@ +import { useContext } from 'react'; +import { + Dialog, + DialogBackdrop, + DialogPanel, + TransitionChild, +} from '@headlessui/react'; +import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; +import Title from './Title'; +import ActionBar from './ActionBar'; +import { AppContext } from 'context/AppContext'; +import { AppActions } from 'context/Reducer'; + +const RootLayout: React.FC<React.PropsWithChildren> = ({ children }) => { + const { state, dispatch } = useContext(AppContext); + + return ( + <div> + <Dialog + open={state.sidebarOpen} + onClose={() => dispatch({ type: AppActions.HIDE_SIDEBAR })} + className="relative z-50 lg:hidden" + > + <DialogBackdrop + transition + className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-closed:opacity-0" + /> + + <div className="fixed inset-0 flex"> + <DialogPanel + transition + className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-closed:-translate-x-full" + > + <TransitionChild> + <div className="absolute top-0 left-full flex w-16 justify-center pt-5 duration-300 ease-in-out data-closed:opacity-0"> + <button + type="button" + onClick={() => dispatch({ type: AppActions.HIDE_SIDEBAR })} + className="-m-2.5 p-2.5" + > + <span className="sr-only">Close sidebar</span> + <XMarkIcon aria-hidden="true" className="size-6 text-white" /> + </button> + </div> + </TransitionChild> + + <div className="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 pb-2 ring-1 ring-white/10"> + <div className="h-16" /> + + <ActionBar /> + </div> + </DialogPanel> + </div> + </Dialog> + + <div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col"> + <div className="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6"> + <div className="flex h-16 shrink-0 items-center"> + <Title /> + </div> + <ActionBar /> + </div> + </div> + + <div className="sticky top-0 z-40 flex items-center gap-x-6 bg-gray-900 px-4 py-4 shadow-xs sm:px-6 lg:hidden"> + <button + type="button" + onClick={() => dispatch({ type: AppActions.SHOW_SIDEBAR })} + className="-m-2.5 p-2.5 text-gray-400 lg:hidden" + > + <span className="sr-only">Open sidebar</span> + <Bars3Icon aria-hidden="true" className="size-6" /> + </button> + <div className="flex-1 text-sm/6 font-semibold text-white"> + <Title /> + </div> + </div> + + <main className="lg:pl-72">{children}</main> + </div> + ); +}; + +export default RootLayout; diff --git a/src/components/Layout/Title.tsx b/src/components/Layout/Title.tsx new file mode 100644 index 0000000..0a44550 --- /dev/null +++ b/src/components/Layout/Title.tsx @@ -0,0 +1,7 @@ +const Title: React.FC = () => { + return ( + <span className="text-yellow-500 text-xl font-semibold">JS Playground</span> + ); +}; + +export default Title; diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx new file mode 100644 index 0000000..52c53ec --- /dev/null +++ b/src/components/Layout/index.tsx @@ -0,0 +1 @@ +export { default } from 'components/Layout/RootLayout'; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx deleted file mode 100644 index 4095633..0000000 --- a/src/components/Modal/Modal.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; - -interface ModalProps extends React.PropsWithChildren { - isOpen: boolean; - onClose: () => void; - title?: string; -} - -const Modal: React.FC<ModalProps> = ({ isOpen, title, onClose, children }) => { - if (!isOpen) return null; - - return ( - <div className="modal fade show" style={{ display: 'block' }}> - <div className="modal-dialog" style={{ maxWidth: 1200 }}> - <div className="modal-content"> - <div className="modal-header text-white bg-dark"> - <h5 className="modal-title">{title}</h5> - <button - type="button" - className="btn-close btn-close-white" - onClick={onClose} - > - <span className="visually-hidden">Close</span> - </button> - </div> - <div - className="modal-body" - style={{ maxHeight: '600px', overflowY: 'auto' }} - > - <div>{children}</div> - </div> - <div className="modal-footer"> - <button - data-testid="modal-close-btn" - type="button" - className="btn btn-primary" - onClick={onClose} - > - Close - </button> - </div> - </div> - </div> - </div> - ); -}; - -export default Modal; diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx deleted file mode 100644 index 447a140..0000000 --- a/src/components/Modal/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'components/Modal/Modal'; diff --git a/src/components/ShareCode/ShareCode.test.tsx b/src/components/ShareCode/ShareCode.test.tsx new file mode 100644 index 0000000..c8e0a05 --- /dev/null +++ b/src/components/ShareCode/ShareCode.test.tsx @@ -0,0 +1,60 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import ShareCode from 'components/ShareCode'; +import { AppContext } from 'context/AppContext'; + +describe('<ShareCode />', () => { + const state = { + shareUrl: 'https://abolkog.github.io/js-playground/?code=sample', + } as AppState; + const dispatch = jest.fn(); + + beforeEach(jest.clearAllMocks); + + it('it render share code modal', async () => { + await act(async () => { + render( + <AppContext.Provider value={{ state, dispatch }}> + <ShareCode /> + </AppContext.Provider>, + ); + }); + const shareTextBox = screen.getByRole('textbox'); + expect(shareTextBox).toBeInTheDocument(); + expect(shareTextBox).toHaveValue(state.shareUrl); + }); + + it('it copy url to clip board on button click', async () => { + Object.defineProperty(global.navigator, 'clipboard', { + value: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + configurable: true, + }); + const clipboardWriteText = jest.spyOn(navigator.clipboard, 'writeText'); + + await act(async () => { + render( + <AppContext.Provider value={{ state, dispatch }}> + <ShareCode /> + </AppContext.Provider>, + ); + }); + const shareButton = screen.getByRole('button'); + fireEvent.click(shareButton); + expect(clipboardWriteText).toHaveBeenCalledWith(state.shareUrl); + }); + + it('it does not render share modal when no url', async () => { + await act(async () => { + render( + <AppContext.Provider + value={{ state: { ...state, shareUrl: '' }, dispatch }} + > + <ShareCode /> + </AppContext.Provider>, + ); + }); + const shareTextBox = screen.queryByRole('textbox'); + expect(shareTextBox).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ShareCode/ShareCode.tsx b/src/components/ShareCode/ShareCode.tsx new file mode 100644 index 0000000..f263d18 --- /dev/null +++ b/src/components/ShareCode/ShareCode.tsx @@ -0,0 +1,86 @@ +import { useContext } from 'react'; +import { AppContext } from 'context/AppContext'; +import { AppActions } from 'context/Reducer'; +import { + Dialog, + DialogBackdrop, + DialogPanel, + DialogTitle, +} from '@headlessui/react'; +import { ShareIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline'; + +const ShareCode: React.FC = () => { + const { state, dispatch } = useContext(AppContext); + + const closeModal = () => { + dispatch({ + type: AppActions.SET_SHARE_URL, + payload: '', + }); + }; + + const onShareButtonClick = async () => { + await navigator.clipboard.writeText(state.shareUrl); + closeModal(); + }; + + return ( + <Dialog + open={state.shareUrl.length > 0} + onClose={closeModal} + className="relative z-10" + > + <DialogBackdrop + transition + className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in" + /> + + <div className="fixed inset-0 z-10 w-screen overflow-y-auto"> + <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> + <DialogPanel + transition + className="relative transform overflow-hidden rounded-lg bg-gray-900 text-white/60 px-4 pt-5 pb-4 text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg sm:p-6 data-closed:sm:translate-y-0 data-closed:sm:scale-95" + > + <div> + <div className="mx-auto flex size-12 items-center justify-center rounded-full bg-green-100"> + <ShareIcon + aria-hidden="true" + className="size-6 text-green-600" + /> + </div> + <div className="mt-3 text-center sm:mt-5"> + <DialogTitle + as="h3" + className="text-base font-semibold text-yellow-500" + > + Share your code + </DialogTitle> + <p>Use the following URL to share code</p> + <div className="mt-2"> + <div className="flex items-center gap-2"> + <input + type="text" + readOnly + value={state.shareUrl} + className="block w-full rounded-md outline-gray-600 bg-gray-600 px-3 py-1.5 text-white outline-1 -outline-offset-1 focus:outline-2 focus:-outline-offset-2 focus:outline-gray-300 sm:text-sm/6" + /> + <button + type="button" + className="p-2 rounded bg-gray-700 hover:bg-gray-600 transition cursor-pointer" + onClick={onShareButtonClick} + aria-label="Copy to clipboard" + > + <ClipboardDocumentIcon className="size-5 text-white" /> + </button> + </div> + </div> + </div> + </div> + </DialogPanel> + </div> + </div> + </Dialog> + ); +}; + +export default ShareCode; diff --git a/src/components/ShareCode/index.tsx b/src/components/ShareCode/index.tsx new file mode 100644 index 0000000..0b2f834 --- /dev/null +++ b/src/components/ShareCode/index.tsx @@ -0,0 +1 @@ +export { default } from 'components/ShareCode/ShareCode'; diff --git a/src/components/Spinner/Spinner.test.tsx b/src/components/Spinner/Spinner.test.tsx new file mode 100644 index 0000000..d262004 --- /dev/null +++ b/src/components/Spinner/Spinner.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react'; +import Spinner from './Spinner'; + +describe('Spinner Component', () => { + it('renders the svg element', () => { + const { container } = render(<Spinner />); + const spinner = container.querySelector('svg.text-gray-300.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); +}); diff --git a/src/components/Spinner/Spinner.tsx b/src/components/Spinner/Spinner.tsx new file mode 100644 index 0000000..cd26fbc --- /dev/null +++ b/src/components/Spinner/Spinner.tsx @@ -0,0 +1,28 @@ +const Spinner: React.FC = () => ( + <svg + className="text-gray-300 animate-spin" + viewBox="0 0 64 64" + fill="none" + xmlns="http://www.w3.org/2000/svg" + width="20" + height="20" + > + <path + d="M32 3C35.8083 3 39.5794 3.75011 43.0978 5.20749C46.6163 6.66488 49.8132 8.80101 52.5061 11.4939C55.199 14.1868 57.3351 17.3837 58.7925 20.9022C60.2499 24.4206 61 28.1917 61 32C61 35.8083 60.2499 39.5794 58.7925 43.0978C57.3351 46.6163 55.199 49.8132 52.5061 52.5061C49.8132 55.199 46.6163 57.3351 43.0978 58.7925C39.5794 60.2499 35.8083 61 32 61C28.1917 61 24.4206 60.2499 20.9022 58.7925C17.3837 57.3351 14.1868 55.199 11.4939 52.5061C8.801 49.8132 6.66487 46.6163 5.20749 43.0978C3.7501 39.5794 3 35.8083 3 32C3 28.1917 3.75011 24.4206 5.2075 20.9022C6.66489 17.3837 8.80101 14.1868 11.4939 11.4939C14.1868 8.80099 17.3838 6.66487 20.9022 5.20749C24.4206 3.7501 28.1917 3 32 3L32 3Z" + stroke="currentColor" + strokeWidth="5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M32 3C36.5778 3 41.0906 4.08374 45.1692 6.16256C49.2477 8.24138 52.7762 11.2562 55.466 14.9605C58.1558 18.6647 59.9304 22.9531 60.6448 27.4748C61.3591 31.9965 60.9928 36.6232 59.5759 40.9762" + stroke="currentColor" + strokeWidth="5" + strokeLinecap="round" + strokeLinejoin="round" + className="text-gray-400" + /> + </svg> +); + +export default Spinner; diff --git a/src/components/Spinner/index.tsx b/src/components/Spinner/index.tsx new file mode 100644 index 0000000..c2ca997 --- /dev/null +++ b/src/components/Spinner/index.tsx @@ -0,0 +1 @@ +export { default } from 'components/Spinner/Spinner'; diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 61373a4..d8408e8 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -1,26 +1,19 @@ import { createContext, useMemo, useReducer } from 'react'; -import { getLocalStorage, STORAGE } from 'services/storage'; +import { getLocalStorage } from 'services/storage'; +import { STORAGE } from 'helpers/const'; import { reducer } from 'context/Reducer'; -const getTheme = (): Theme => { - const themeFromStorage = getLocalStorage(STORAGE.THEME) as Theme; - if (themeFromStorage === 'vs-dark' || themeFromStorage === 'vs-light') - return themeFromStorage; - return 'vs-dark'; -}; - const initialState: AppState = { code: getLocalStorage(STORAGE.CODE), codeSample: '', codeSampleName: '', result: [], error: '', + shareUrl: '', loading: false, - theme: getTheme(), - display: 'none', - position: null, - jsonView: 'none', - historyModalShown: false, + sidebarOpen: false, + historyOpen: false, + aboutModalOpen: false, }; export const AppContext = createContext<{ diff --git a/src/context/Reducer.spec.ts b/src/context/Reducer.test.ts similarity index 51% rename from src/context/Reducer.spec.ts rename to src/context/Reducer.test.ts index 8a68ad9..a7d31ae 100644 --- a/src/context/Reducer.spec.ts +++ b/src/context/Reducer.test.ts @@ -7,12 +7,11 @@ describe('Reducer tests', () => { codeSampleName: '', result: [], error: '', + shareUrl: '', loading: false, - theme: 'vs-dark', - display: 'none', - position: null, - jsonView: '', - historyModalShown: false, + sidebarOpen: false, + historyOpen: false, + aboutModalOpen: false, }; it('update and persist code when update code action is dispatched', () => { @@ -55,22 +54,6 @@ describe('Reducer tests', () => { expect(state.error).toEqual('error'); }); - it('update about modal flag when toggle modal action is dispatched', () => { - const state = reducer(INITIAL_STATE, { - type: AppActions.TOGGLE_ABOUT_MODAL, - payload: 'block', - }); - expect(state.display).toEqual('block'); - }); - - it('update theme state when toggle theme action is dispatched', () => { - const state = reducer(INITIAL_STATE, { - type: AppActions.TOGGLE_THEME, - payload: 'vs-light', - }); - expect(state.theme).toEqual('vs-light'); - }); - it('update code sample value when load code sample action is dispatched', () => { const payload = { codeSample: 'axios code', @@ -90,4 +73,67 @@ describe('Reducer tests', () => { }); expect(state).toEqual(INITIAL_STATE); }); + + describe('show and hide about modal', () => { + it('update sidebar value when show about modal is dispatched', () => { + const state = reducer(INITIAL_STATE, { + type: AppActions.SHOW_ABOUT_MODAL, + }); + expect(state.sidebarOpen).toEqual(false); + expect(state.aboutModalOpen).toEqual(true); + }); + + it('update sidebar value when hide about modal is dispatched', () => { + const state = reducer(INITIAL_STATE, { + type: AppActions.HIDE_ABOUT_MODAL, + }); + expect(state.sidebarOpen).toEqual(false); + expect(state.aboutModalOpen).toEqual(false); + }); + }); + + describe('show and hide history', () => { + it('update sidebar value when show history is dispatched', () => { + const state = reducer(INITIAL_STATE, { + type: AppActions.SHOW_HISTORY, + }); + expect(state.sidebarOpen).toEqual(false); + expect(state.historyOpen).toEqual(true); + }); + + it('update sidebar value when hide history is dispatched', () => { + const state = reducer(INITIAL_STATE, { + type: AppActions.HIDE_HISTORY, + }); + expect(state.sidebarOpen).toEqual(false); + expect(state.historyOpen).toEqual(false); + }); + }); + + describe('toggle sidebar', () => { + it('toggle sidebar open when toggle sidebar is dispatched', () => { + const state = reducer(INITIAL_STATE, { + type: AppActions.SHOW_SIDEBAR, + }); + expect(state.sidebarOpen).toEqual(true); + }); + + it('toggle sidebar close when toggle sidebar is dispatched again', () => { + const initialState = { ...INITIAL_STATE, sidebarOpen: true }; + const state = reducer(initialState, { + type: AppActions.HIDE_SIDEBAR, + }); + expect(state.sidebarOpen).toEqual(false); + }); + }); + + it('update shareUrl when set share url is dispatched', () => { + const payload = 'share-url'; + + const state = reducer(INITIAL_STATE, { + type: AppActions.SET_SHARE_URL, + payload, + }); + expect(state.shareUrl).toEqual(payload); + }); }); diff --git a/src/context/Reducer.ts b/src/context/Reducer.ts index 0eb520b..db23f8d 100644 --- a/src/context/Reducer.ts +++ b/src/context/Reducer.ts @@ -1,4 +1,5 @@ -import { saveToHistory, setLocalStorage, STORAGE } from 'services/storage'; +import { saveToHistory, setLocalStorage } from 'services/storage'; +import { STORAGE } from 'helpers/const'; export const AppActions = { UPDATE_CODE: 'UPDATE_CODE', @@ -6,12 +7,15 @@ export const AppActions = { CODE_RUNNING: 'CODE_RUNNING', CODE_RUN_SUCCESS: 'CODE_RUN_SUCCESS', CODE_RUN_ERROR: 'CODE_RUN_ERROR', - TOGGLE_ABOUT_MODAL: 'TOGGLE_ABOUT_MODAL', - TOGGLE_JSON_VIEW: 'TOGGLE_JSON_VIEW', - TOGGLE_THEME: 'TOGGLE_THEME', CLEAR_RESULT: 'CLEAR_RESULT', LOAD_CODE_SAMPLE: 'LOAD_CODE_SAMPLE', - TOGGLE_HISTORY_MODAL: 'TOGGLE_HISTORY_MODAL', + SHOW_SIDEBAR: 'SHOW_SIDEBAR', + HIDE_SIDEBAR: 'HIDE_SIDEBAR', + SHOW_HISTORY: 'SHOW_HISTORY', + HIDE_HISTORY: 'HIDE_HISTORY', + SHOW_ABOUT_MODAL: 'SHOW_ABOUT_MODAL', + HIDE_ABOUT_MODAL: 'HIDE_ABOUT_MODAL', + SET_SHARE_URL: 'SET_SHARE_URL', }; const handleCodeUpdate = (state: AppState, action: Action): AppState => { @@ -25,7 +29,7 @@ const handleCodeSuccess = (state: AppState, action: Action): AppState => { if (action.payload) { result.push(action.payload); } - return { ...state, error: '', result, loading: false }; + return { ...state, error: '', result, loading: false, sidebarOpen: false }; }; const handleLoadCodeSample = (state: AppState, action: Action): AppState => { @@ -33,7 +37,7 @@ const handleLoadCodeSample = (state: AppState, action: Action): AppState => { string, string >; - return { ...state, codeSample, codeSampleName }; + return { ...state, codeSample, codeSampleName, sidebarOpen: false }; }; const handleCodeRunning = (state: AppState): AppState => { @@ -41,11 +45,6 @@ const handleCodeRunning = (state: AppState): AppState => { return { ...state, loading: true }; }; -const setAppTheme = (state: AppState, action: Action) => { - setLocalStorage(STORAGE.THEME, action.payload as string); - return { ...state, theme: action.payload as Theme }; -}; - export const reducer = (state: AppState, action: Action): AppState => { switch (action.type) { case AppActions.UPDATE_CODE: @@ -56,18 +55,43 @@ export const reducer = (state: AppState, action: Action): AppState => { return handleCodeSuccess(state, action); case AppActions.CODE_RUN_ERROR: return { ...state, error: action.payload as string, loading: false }; - case AppActions.TOGGLE_ABOUT_MODAL: - return { ...state, display: action.payload as DisplayType }; - case AppActions.TOGGLE_JSON_VIEW: - return { ...state, jsonView: action.payload as DisplayType }; - case AppActions.TOGGLE_THEME: - return setAppTheme(state, action); + case AppActions.SHOW_ABOUT_MODAL: + return { + ...state, + sidebarOpen: false, + aboutModalOpen: true, + }; + case AppActions.HIDE_ABOUT_MODAL: + return { + ...state, + sidebarOpen: false, + aboutModalOpen: false, + }; case AppActions.LOAD_CODE_SAMPLE: return handleLoadCodeSample(state, action); case AppActions.CLEAR_RESULT: - return { ...state, result: [] }; - case AppActions.TOGGLE_HISTORY_MODAL: - return { ...state, historyModalShown: !state.historyModalShown }; + return { ...state, sidebarOpen: false, result: [] }; + case AppActions.SHOW_HISTORY: + return { + ...state, + sidebarOpen: false, + historyOpen: true, + }; + case AppActions.HIDE_HISTORY: + return { + ...state, + sidebarOpen: false, + historyOpen: false, + }; + case AppActions.SHOW_SIDEBAR: + return { ...state, sidebarOpen: true }; + case AppActions.HIDE_SIDEBAR: + return { ...state, sidebarOpen: false }; + case AppActions.SET_SHARE_URL: + return { + ...state, + shareUrl: action.payload as string, + }; default: return state; } diff --git a/src/context/types.d.ts b/src/context/types.d.ts index 04020e6..b8b80ed 100644 --- a/src/context/types.d.ts +++ b/src/context/types.d.ts @@ -1,21 +1,31 @@ -type Theme = 'vs-dark' | 'vs-light'; -type DisplayType = 'none' | 'block'; - -interface AppState { +type AppState = { code: string; codeSample: string; codeSampleName: string; result: unknown[]; error: string; loading: boolean; - theme: Theme; - display: DisplayType; - position: null; - jsonView: string; - historyModalShown: boolean; -} + sidebarOpen: boolean; + historyOpen: boolean; + aboutModalOpen: boolean; + shareUrl: string; +}; + +type Payload = { + codeSample?: string; + codeSampleName?: string; +}; -interface Action { +type Action = { type: string; - payload?: unknown; // FIXME: String it ? -} + payload?: Payload | string; +}; + +type ActionBarChildItem = Pick<ActionBarItem, 'label' | 'payload'>; + +type ActionBarItem = { + label: string; + icon: React.FC<React.SVGProps<SVGSVGElement>>; + payload?: Action; + children?: ActionBarChildItem[]; +}; diff --git a/src/helpers/const.ts b/src/helpers/const.ts index c7a4fb3..ecd79a3 100644 --- a/src/helpers/const.ts +++ b/src/helpers/const.ts @@ -101,15 +101,11 @@ console.log(calculate({ operation: '*', operand1: 4, operand2: 6 })); // Outpu }, ]; -export const EDITOR_THEMES: EditorTheme[] = [ - { - id: 1, - value: 'vs-dark', - icon: 'moon', - }, - { - id: 2, - value: 'vs-light', - icon: 'sun', - }, -]; +export const MAX_SHARE_CODE_LENGTH = 2000; + +export const MAX_HISTORY_SIZE = 20; + +export const STORAGE = { + CODE: '@abolkog/jscode', + HISTORY: 'abolkog/jscode-history', +}; diff --git a/src/helpers/global.ts b/src/helpers/global.ts index 195e85c..cde58f1 100644 --- a/src/helpers/global.ts +++ b/src/helpers/global.ts @@ -11,3 +11,6 @@ _.extend(window, { dfn, Redux, }); + +export const classNames = (...classes: string[]) => + classes.filter(Boolean).join(' '); diff --git a/src/helpers/type.d.ts b/src/helpers/type.d.ts index a44374d..28fb2c0 100644 --- a/src/helpers/type.d.ts +++ b/src/helpers/type.d.ts @@ -1,23 +1,17 @@ -interface CodeSample { +type CodeSample = { id: number; name: string; codeSample: string; -} +}; -interface EditorTheme { - id: number; - value: Theme; - icon: string; -} - -interface Library { +type Library = { name: string; url: string; use: string; version: string; -} +}; -interface HistoryItem { +type HistoryItem = { code: string; date: string; -} +}; diff --git a/src/hooks/useCodeRunner.test.tsx b/src/hooks/useCodeRunner.test.tsx new file mode 100644 index 0000000..108dbe7 --- /dev/null +++ b/src/hooks/useCodeRunner.test.tsx @@ -0,0 +1,60 @@ +import { ReactNode } from 'react'; +import { renderHook, act } from '@testing-library/react'; +import useCodeRunner from './useCodeRunner'; +import { AppActions } from 'context/Reducer'; +import { AppContext } from 'context/AppContext'; + +describe('useCodeRunner', () => { + const state = {} as AppState; + let dispatch: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + dispatch = jest.fn(); + }); + + const createWrapper = ({ children }: { children: ReactNode }) => ( + <AppContext.Provider value={{ state, dispatch }}> + {children} + </AppContext.Provider> + ); + + it('should dispatch CODE_RUNNING and CODE_RUN_SUCCESS on valid code', async () => { + const { result } = renderHook(() => useCodeRunner(), { + wrapper: createWrapper, + }); + + const code = 'const a = 2 + 2; a;'; + + await act(async () => { + await result.current.runCode(code); + }); + + expect(dispatch).toHaveBeenNthCalledWith(1, { + type: AppActions.CODE_RUNNING, + }); + + expect(dispatch).toHaveBeenNthCalledWith(2, { + type: AppActions.CODE_RUN_SUCCESS, + payload: 4, + }); + }); + + it('should dispatch CODE_RUN_ERROR on invalid code', async () => { + const { result } = renderHook(() => useCodeRunner(), { + wrapper: createWrapper, + }); + + const code = 'throw new Error("Invalid code dude");'; + + await act(async () => { + await result.current.runCode(code); + }); + + expect(dispatch).toHaveBeenCalledWith({ type: AppActions.CODE_RUNNING }); + expect(dispatch).toHaveBeenCalledWith({ + type: AppActions.CODE_RUN_ERROR, + payload: 'Invalid code dude', + }); + }); +}); diff --git a/src/hooks/useCodeRunner.tsx b/src/hooks/useCodeRunner.tsx index cc1e9ad..6d6cc5e 100644 --- a/src/hooks/useCodeRunner.tsx +++ b/src/hooks/useCodeRunner.tsx @@ -6,7 +6,7 @@ import { AppActions } from 'context/Reducer'; const useCodeRunner = () => { const { dispatch } = useContext(AppContext); - const evalCode = (code: string) => + const evalCode = (code: string): Promise<string> => new Promise((resolve, reject) => { try { const result = eval(code); diff --git a/src/index.tsx b/src/index.tsx index e4aea06..45047a6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,12 +3,16 @@ import ReactDOM from 'react-dom/client'; import App from 'components/App'; import 'helpers/global'; import { AppProvider } from 'context/AppContext'; +import RootLayout from 'components/Layout'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, ); + root.render( <AppProvider> - <App /> + <RootLayout> + <App /> + </RootLayout> </AppProvider>, ); diff --git a/src/services/storage.spec.ts b/src/services/storage.spec.ts deleted file mode 100644 index 7ce09dc..0000000 --- a/src/services/storage.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as StorageService from 'services/storage'; - -describe('Storage tests', () => { - const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); - - it('Save value to locale storage', () => { - StorageService.setLocalStorage('mock', 'value'); - expect(setItemSpy).toHaveBeenCalledWith('mock', 'value'); - }); - - it('Get value from locale storage', () => { - const getItemSpy = jest.spyOn(Storage.prototype, 'getItem'); - StorageService.getLocalStorage('value'); - expect(getItemSpy).toHaveBeenCalledWith('value'); - }); - - it('use fallback/default value when nothing found in localstorage', () => { - jest.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce(null); - const defaultValue = 'default'; - const result = StorageService.getLocalStorage('value', defaultValue); - expect(result).toEqual(defaultValue); - }); - - it('Remove value from locale storage', () => { - const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); - StorageService.clearLocalStorage('value'); - expect(removeItemSpy).toHaveBeenCalledWith('value'); - }); -}); diff --git a/src/services/storage.test.ts b/src/services/storage.test.ts new file mode 100644 index 0000000..b5dba46 --- /dev/null +++ b/src/services/storage.test.ts @@ -0,0 +1,115 @@ +import { compress } from 'lz-string'; +import { STORAGE } from 'helpers/const'; +import * as StorageService from 'services/storage'; + +jest.mock('../helpers/const', () => { + const actual = jest.requireActual('../helpers/const'); + return { + ...actual, + MAX_HISTORY_SIZE: 2, + }; +}); + +describe('Storage tests', () => { + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + const today = '2025-05-26'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date(today)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('Save value to locale storage', () => { + StorageService.setLocalStorage('mock', 'value'); + expect(setItemSpy).toHaveBeenCalledWith('mock', 'value'); + }); + + it('Get value from locale storage', () => { + const getItemSpy = jest.spyOn(Storage.prototype, 'getItem'); + StorageService.getLocalStorage('value'); + expect(getItemSpy).toHaveBeenCalledWith('value'); + }); + + it('use fallback/default value when nothing found in localstorage', () => { + jest.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce(null); + const defaultValue = 'default'; + const result = StorageService.getLocalStorage('value', defaultValue); + expect(result).toEqual(defaultValue); + }); + + it('Remove value from locale storage', () => { + const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); + StorageService.clearLocalStorage('value'); + expect(removeItemSpy).toHaveBeenCalledWith('value'); + }); + + describe('getHistory', () => { + const mockCode = 'console.log(1)'; + beforeEach(jest.clearAllMocks); + + it('return empty array when no history found', () => { + jest.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce(null); + const result = StorageService.getHistory(); + expect(result).toEqual([]); + }); + + it('return history result', () => { + const mockHistory = [ + { code: 'test', date: today }, + { code: 'test2', date: today }, + ]; + jest + .spyOn(Storage.prototype, 'getItem') + .mockReturnValueOnce(JSON.stringify(mockHistory)); + const result = StorageService.getHistory(); + expect(result.length).toEqual(mockHistory.length); + }); + + it('save code to history', () => { + jest.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce(null); + + StorageService.saveToHistory(mockCode); + expect(setItemSpy).toHaveBeenCalledWith( + STORAGE.HISTORY, + JSON.stringify([{ code: compress(mockCode), date: today }]), + ); + }); + + it('remove oldest item in history if max reached', () => { + jest.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce( + JSON.stringify([ + { code: compress('console.log(2)'), date: today }, + { code: compress('console.log(3)'), date: today }, + { code: compress('console.log(4)'), date: today }, + ]), + ); + + StorageService.saveToHistory(mockCode); + expect(setItemSpy).toHaveBeenCalledWith( + STORAGE.HISTORY, + JSON.stringify([ + { code: compress('console.log(3)'), date: today }, + { code: compress('console.log(4)'), date: today }, + { code: compress(mockCode), date: today }, + ]), + ); + }); + + it('does not save code to history if it already exist', () => { + const mockCode = 'console.log(1)'; + jest + .spyOn(Storage.prototype, 'getItem') + .mockReturnValueOnce( + JSON.stringify([{ code: compress(mockCode), date: today }]), + ); + + StorageService.saveToHistory(mockCode); + expect(setItemSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/storage.ts b/src/services/storage.ts index 68ad5df..038d216 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,12 +1,5 @@ import { compress, decompress } from 'lz-string'; - -const MAX_HISTORY_SIZE = 20; - -export const STORAGE = { - CODE: '@abolkog/jscode', - HISTORY: 'abolkog/jscode-history', - THEME: 'abolkog/jscode-theme', -}; +import { MAX_HISTORY_SIZE, STORAGE } from 'helpers/const'; export const getLocalStorage = (key: string, defaultValue = '') => { return localStorage.getItem(key) || defaultValue; @@ -22,7 +15,7 @@ export const clearLocalStorage = (key: string) => { export const saveToHistory = (code: string) => { const history: HistoryItem[] = JSON.parse( - localStorage.getItem(STORAGE.HISTORY) || '[]' + localStorage.getItem(STORAGE.HISTORY) || '[]', ); // Compress the code before saving diff --git a/src/styles/App.css b/src/styles/App.css index 37c09f3..365eba9 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -1,143 +1,19 @@ -html, -body { - height: 100%; - margin: 0; - padding: 0; -} - -#root { - display: flex; - flex: 1; - height: 100%; -} - -.flex { - display: flex; - flex: 1; -} - -.flexColumn { - flex-direction: column; - overflow: hidden; -} +@import 'tailwindcss'; -.editorContainer { - height: 70vh; +@theme { + --font-sans: InterVariable, sans-serif; } -.consoleContainer { - height: 20vh; +.split { display: flex; - flex: 1 -} - - -.monaco-editor, -.overflow-guard { - width: 100% !important; -} - -.console { - word-wrap: break-word; - word-break: break-all; - font-size: large; - font-family: sans-serif; - padding: 20px; - overflow-y: auto; - width: 100%; -} - -.console-light { - background-color: #fff; - color: #3a3f44; - border-top: 1px solid #3a3f44; -} - -.error { - color: #e54e4e; -} - -.small-text { - font-size: 11px; -} - -.app-actions { - display: flex; - justify-content: center; - align-items: center; -} - -.menu { - position: fixed; - list-style: none; - user-select: none; - background-color: #ffffff; - box-sizing: border-box; - box-shadow: 0px 10px 30px -5px rgba(0, 0, 0, 0.3); - border-radius: 6px; - padding: 6px 10px; - min-width: 200px; - z-index: 100; -} - -.menu li { - cursor: pointer; - padding: 0px 5px; -} - -.menu li:hover { - background-color: #3a3f44; -} - -.clickable { - background: none; - color: inherit; - border: none; - padding: 5px; - font: inherit; - outline: 0; - cursor: pointer; - width: 100%; - text-align: left; -} - - -.text-sm { - color: #aaa; - font-size: 0.7rem; + height: 100%; } - - -.modalOverlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; +.gutter { + background-color: #444; + cursor: col-resize; } -.modalContent { - background-color: white; - padding: 20px; - border-radius: 8px; - max-width: 500px; - width: 100%; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - position: relative; +.gutter.gutter-horizontal { + width: 6px; } - -.closeButton { - position: absolute; - top: 10px; - right: 10px; - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; -} \ No newline at end of file