diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..6392176 --- /dev/null +++ b/.npmignore @@ -0,0 +1,44 @@ +# Development files +node_modules/ +.git/ +.gitignore +.eslintrc.js +.stylelintrc.json + +# Build artifacts that aren't needed +webpack.config.js +babel.config.js + +# Documentation that's not essential for npm users +.github/ +docs/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# Temporary folders +tmp/ +temp/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 47a6037..5f09758 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "adminator", - "version": "2.6.1", + "name": "adminator-admin-dashboard", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "adminator", - "version": "2.6.1", + "name": "adminator-admin-dashboard", + "version": "2.7.0", + "license": "MIT", "dependencies": { "@fullcalendar/core": "^6.1.17", "@fullcalendar/daygrid": "^6.1.17", @@ -33,6 +34,11 @@ "@babel/preset-env": "^7.27.2", "@babel/runtime": "^7.27.6", "@eslint/js": "^9.29.0", + "@types/lodash": "^4.17.20", + "@types/masonry-layout": "^4.2.8", + "@types/node": "^24.0.12", + "@typescript-eslint/eslint-plugin": "^8.36.0", + "@typescript-eslint/parser": "^8.36.0", "babel-loader": "^10.0.0", "case-sensitive-paths-webpack-plugin": "^2.4.0", "copy-webpack-plugin": "^13.0.0", @@ -53,10 +59,15 @@ "style-loader": "^4.0.0", "stylelint": "^16.21.0", "stylelint-config-standard": "^38.0.0", + "ts-loader": "^9.5.2", + "typescript": "^5.8.3", "webpack": "^5.99.9", "webpack-cli": "^6.0.1", "webpack-dashboard": "^3.3.8", "webpack-dev-server": "^5.2.2" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/@ampproject/remapping": { @@ -4293,12 +4304,39 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jquery": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.32.tgz", + "integrity": "sha512-b9Xbf4CkMqS02YH8zACqN1xzdxc3cO735Qe5AbSUFmyOiaWAbcpqh9Wna+Uk0vgACvoQHpWDg2rGdHkYPLmCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sizzle": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/masonry-layout": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@types/masonry-layout/-/masonry-layout-4.2.8.tgz", + "integrity": "sha512-Et2to22C31FG1UFaHRBL6BznMOhrur3Ckr9gvR7fRVmPgxqiwCEKZtV8GpFscHyNAKhZ0QlkwXJRPnJvxZUKQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jquery": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4307,9 +4345,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.1.tgz", - "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==", + "version": "24.0.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", + "integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==", "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -4379,6 +4417,13 @@ "@types/send": "*" } }, + "node_modules/@types/sizzle": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.9.tgz", + "integrity": "sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sockjs": { "version": "0.3.36", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", @@ -4416,6 +4461,289 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", + "integrity": "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/type-utils": "8.36.0", + "@typescript-eslint/utils": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.36.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.36.0.tgz", + "integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/typescript-estree": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz", + "integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.36.0", + "@typescript-eslint/types": "^8.36.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz", + "integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz", + "integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.36.0.tgz", + "integrity": "sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.36.0", + "@typescript-eslint/utils": "8.36.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz", + "integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz", + "integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.36.0", + "@typescript-eslint/tsconfig-utils": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz", + "integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/typescript-estree": "8.36.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz", + "integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.36.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -7657,6 +7985,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -12921,6 +13256,63 @@ "tslib": "2" } }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-loader": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", + "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -12955,6 +13347,20 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", diff --git a/src/assets/scripts/app.js b/src/assets/scripts/app.js index 15a5922..ccf80d6 100644 --- a/src/assets/scripts/app.js +++ b/src/assets/scripts/app.js @@ -5,11 +5,11 @@ // Note: Bootstrap 5 CSS is still available via SCSS imports // Bootstrap JS components removed to eliminate jQuery dependency -import DOM from './utils/dom'; +import { DOM } from './utils/dom'; import DateUtils from './utils/date'; -import Theme from './utils/theme'; -import Sidebar from './components/Sidebar'; -import ChartComponent from './components/Chart'; +import { ThemeManager } from './utils/theme'; +import { Sidebar } from './components/Sidebar'; +import { ChartComponent } from './components/Chart'; // Import styles import '../styles/index.scss'; @@ -31,6 +31,7 @@ class AdminatorApp { constructor() { this.components = new Map(); this.isInitialized = false; + this.themeManager = ThemeManager; // Initialize when DOM is ready DOM.ready(() => { @@ -212,7 +213,7 @@ class AdminatorApp { // Initializing theme system // Initialize theme system first - Theme.init(); + this.themeManager.init(); // Inject theme toggle if missing - with retry mechanism setTimeout(() => { @@ -249,11 +250,11 @@ class AdminatorApp { const toggle = DOM.select('#theme-toggle'); if (toggle) { // Set initial state - const currentTheme = Theme.current(); + const currentTheme = this.themeManager.current(); toggle.checked = currentTheme === 'dark'; DOM.on(toggle, 'change', () => { - Theme.apply(toggle.checked ? 'dark' : 'light'); + this.themeManager.apply(toggle.checked ? 'dark' : 'light'); }); // Listen for theme changes from other sources diff --git a/src/assets/scripts/app.ts b/src/assets/scripts/app.ts new file mode 100644 index 0000000..142f8a8 --- /dev/null +++ b/src/assets/scripts/app.ts @@ -0,0 +1,757 @@ +/** + * Modern Adminator Application with TypeScript + * Main application entry point with enhanced mobile support and type safety + */ + +import { DOM } from './utils/dom'; +import { ThemeManager } from './utils/theme'; +import { Sidebar } from './components/Sidebar'; +import { ChartComponent } from './components/Chart'; +import UIComponents from './ui'; +import DataTable from './datatable'; +import DatePicker from './datepicker'; +import VectorMaps from './vectorMaps'; +import type { ComponentInterface } from '../../types'; + +// Import styles +import '../styles/index.scss'; + +// Import other modules that don't need immediate modernization +import './fullcalendar'; +import './masonry'; +import './popover'; +import './scrollbar'; +import './search'; +import './skycons'; +import './chat'; +import './email'; +import './googleMaps'; + +// Type definitions for the application +export interface AdminatorAppOptions { + autoInit?: boolean; + theme?: 'light' | 'dark' | 'auto'; + mobile?: { + enhanced?: boolean; + fullWidthSearch?: boolean; + disableDropdowns?: boolean; + }; + debug?: boolean; +} + +export interface AdminatorAppState { + isInitialized: boolean; + isMobile: boolean; + currentTheme: 'light' | 'dark' | 'auto'; + components: Map; +} + +export interface AdminatorAppEvents { + ready: CustomEvent<{ app: AdminatorApp }>; + themeChanged: CustomEvent<{ theme: string; previousTheme: string }>; + mobileStateChanged: CustomEvent<{ isMobile: boolean }>; + componentAdded: CustomEvent<{ name: string; component: ComponentInterface }>; + componentRemoved: CustomEvent<{ name: string }>; +} + +declare global { + interface Window { + AdminatorApp?: AdminatorApp; + } +} + +export class AdminatorApp { + public options: AdminatorAppOptions; + public state: AdminatorAppState; + + private resizeTimeout: number | null = null; + private eventHandlers: Map = new Map(); + private themeManager: typeof ThemeManager; + + constructor(options: AdminatorAppOptions = {}) { + this.options = { + autoInit: true, + theme: 'auto', + mobile: { + enhanced: true, + fullWidthSearch: true, + disableDropdowns: false, + }, + debug: false, + ...options, + }; + + this.themeManager = ThemeManager; + + this.state = { + isInitialized: false, + isMobile: this.checkMobileState(), + currentTheme: 'light', + components: new Map(), + }; + + if (this.options.autoInit) { + // Initialize when DOM is ready + DOM.ready(() => { + this.init(); + }); + } + } + + /** + * Initialize the application + */ + public init(): void { + if (this.state.isInitialized) return; + + this.log('Initializing Adminator App...'); + + try { + // Initialize core components + this.initSidebar(); + this.initCharts(); + this.initDataTables(); + this.initDatePickers(); + this.initUIComponents(); + this.initVectorMaps(); + this.initTheme(); + this.initMobileEnhancements(); + + // Setup global event listeners + this.setupGlobalEvents(); + + this.state.isInitialized = true; + this.log('Adminator App initialized successfully'); + + // Dispatch custom event for other scripts + this.dispatchEvent('ready', { app: this }); + + } catch (error) { + console.error('Error initializing Adminator App:', error); + } + } + + /** + * Initialize Sidebar component + */ + private initSidebar(): void { + if (DOM.exists('.sidebar')) { + const sidebar = new Sidebar(); + this.addComponent('sidebar', sidebar); + this.log('Sidebar component initialized'); + } + } + + /** + * Initialize Chart components + */ + private initCharts(): void { + // Check if we have any chart elements + const hasCharts = DOM.exists('#sparklinedash') || + DOM.exists('.sparkline') || + DOM.exists('.sparkbar') || + DOM.exists('.sparktri') || + DOM.exists('.sparkdisc') || + DOM.exists('.sparkbull') || + DOM.exists('.sparkbox') || + DOM.exists('.easy-pie-chart') || + DOM.exists('#line-chart') || + DOM.exists('#area-chart') || + DOM.exists('#scatter-chart') || + DOM.exists('#bar-chart'); + + if (hasCharts) { + const charts = new ChartComponent(); + this.addComponent('charts', charts); + this.log('Chart components initialized'); + } + } + + /** + * Initialize DataTables + */ + private initDataTables(): void { + const dataTableElement = DOM.select('#dataTable'); + if (dataTableElement) { + DataTable.init(); + this.log('DataTable initialized'); + } + } + + /** + * Initialize Date Pickers + */ + private initDatePickers(): void { + const startDatePickers = DOM.selectAll('.start-date'); + const endDatePickers = DOM.selectAll('.end-date'); + + if (startDatePickers.length > 0 || endDatePickers.length > 0) { + DatePicker.init(); + this.log('Date pickers initialized'); + } + } + + /** + * Initialize UI Components + */ + private initUIComponents(): void { + UIComponents.init(); + this.log('UI components initialized'); + } + + /** + * Initialize Vector Maps + */ + private initVectorMaps(): void { + if (DOM.exists('#world-map-marker')) { + VectorMaps.init(); + this.log('Vector maps initialized'); + } + } + + /** + * Initialize theme system with toggle + */ + private initTheme(): void { + this.log('Initializing theme system...'); + + // Initialize theme system first + this.themeManager.init(); + this.state.currentTheme = this.themeManager.current(); + + // Inject theme toggle if missing + setTimeout(() => { + this.injectThemeToggle(); + }, 100); + } + + /** + * Inject theme toggle button + */ + private injectThemeToggle(): void { + const navRight = DOM.select('.nav-right'); + + if (navRight && !DOM.exists('#theme-toggle')) { + const li = document.createElement('li'); + li.className = 'theme-toggle d-flex ai-c'; + li.innerHTML = ` +
+ + + +
+ `; + + // Insert before user dropdown (last item) + const lastItem = navRight.querySelector('li:last-child'); + if (lastItem && lastItem.parentNode === navRight) { + navRight.insertBefore(li, lastItem); + } else { + navRight.appendChild(li); + } + + this.setupThemeToggle(); + this.log('Theme toggle injected'); + } + } + + /** + * Setup theme toggle functionality + */ + private setupThemeToggle(): void { + const toggle = DOM.select('#theme-toggle') as HTMLInputElement; + if (!toggle) return; + + // Set initial state + toggle.checked = this.state.currentTheme === 'dark'; + + // Add change handler + const changeHandler = (): void => { + const newTheme = toggle.checked ? 'dark' : 'light'; + const previousTheme = this.state.currentTheme; + + this.themeManager.apply(newTheme); + this.state.currentTheme = newTheme; + + this.dispatchEvent('themeChanged', { theme: newTheme, previousTheme }); + }; + + DOM.on(toggle, 'change', changeHandler); + this.eventHandlers.set('theme-toggle', changeHandler); + + // Listen for theme changes from other sources + const themeChangeHandler = (event: CustomEvent): void => { + const newTheme = event.detail.theme; + toggle.checked = newTheme === 'dark'; + this.state.currentTheme = newTheme; + + // Update charts when theme changes + const charts = this.getComponent('charts') as ChartComponent; + if (charts && typeof charts.redrawCharts === 'function') { + charts.redrawCharts(); + } + }; + + window.addEventListener('adminator:themeChanged', themeChangeHandler as EventListener); + this.eventHandlers.set('theme-change', themeChangeHandler as EventListener); + } + + /** + * Initialize mobile-specific enhancements + */ + private initMobileEnhancements(): void { + if (!this.options.mobile?.enhanced) return; + + this.log('Initializing mobile enhancements...'); + this.enhanceMobileDropdowns(); + this.enhanceMobileSearch(); + + // Prevent horizontal scroll on mobile + if (this.state.isMobile) { + document.body.style.overflowX = 'hidden'; + } + } + + /** + * Setup global event listeners + */ + private setupGlobalEvents(): void { + // Global click handler + const globalClickHandler = (event: Event): void => { + this.handleGlobalClick(event); + }; + DOM.on(document, 'click', globalClickHandler); + this.eventHandlers.set('global-click', globalClickHandler); + + // Window resize handler with debouncing + const resizeHandler = (): void => { + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + } + this.resizeTimeout = window.setTimeout(() => { + this.handleResize(); + }, 250); + }; + DOM.on(window, 'resize', resizeHandler); + this.eventHandlers.set('resize', resizeHandler); + + this.log('Global event listeners set up'); + } + + /** + * Handle window resize events + */ + private handleResize(): void { + const wasMobile = this.state.isMobile; + this.state.isMobile = this.checkMobileState(); + + if (wasMobile !== this.state.isMobile) { + this.dispatchEvent('mobileStateChanged', { isMobile: this.state.isMobile }); + } + + this.log('Window resized, updating mobile features'); + + // Close all mobile-specific overlays when switching to desktop + if (!this.state.isMobile) { + document.body.style.overflow = ''; + document.body.style.overflowX = ''; + + // Close dropdowns + const dropdowns = DOM.selectAll('.nav-right .dropdown'); + dropdowns.forEach(dropdown => { + dropdown.classList.remove('show'); + const menu = dropdown.querySelector('.dropdown-menu'); + if (menu) menu.classList.remove('show'); + }); + + // Close search + this.closeSearch(); + } else { + // Re-enable mobile overflow protection + document.body.style.overflowX = 'hidden'; + } + + // Re-apply mobile enhancements + if (this.options.mobile?.enhanced) { + this.enhanceMobileDropdowns(); + this.enhanceMobileSearch(); + } + } + + /** + * Handle global click events + */ + private handleGlobalClick(event: Event): void { + const target = event.target as HTMLElement; + + // Close mobile dropdowns when clicking outside + if (!target.closest('.dropdown')) { + const dropdowns = DOM.selectAll('.nav-right .dropdown'); + dropdowns.forEach(dropdown => { + dropdown.classList.remove('show'); + const menu = dropdown.querySelector('.dropdown-menu'); + if (menu) menu.classList.remove('show'); + }); + document.body.style.overflow = ''; + } + + // Close search when clicking outside + if (!target.closest('.search-box') && !target.closest('.search-input')) { + this.closeSearch(); + } + } + + /** + * Check if we're on a mobile device + */ + private checkMobileState(): boolean { + return window.innerWidth <= 768; + } + + /** + * Enhanced mobile dropdown handling + */ + private enhanceMobileDropdowns(): void { + if (!this.state.isMobile || this.options.mobile?.disableDropdowns) return; + + const dropdowns = DOM.selectAll('.nav-right .dropdown'); + + dropdowns.forEach(dropdown => { + const toggle = dropdown.querySelector('.dropdown-toggle') as HTMLElement; + const menu = dropdown.querySelector('.dropdown-menu') as HTMLElement; + + if (toggle && menu) { + // Remove existing listeners to prevent duplicates + const newToggle = toggle.cloneNode(true) as HTMLElement; + toggle.replaceWith(newToggle); + + // Add click functionality for mobile dropdowns + DOM.on(newToggle, 'click', (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + + // Close search if open + this.closeSearch(); + + // Close other dropdowns first + dropdowns.forEach(otherDropdown => { + if (otherDropdown !== dropdown) { + otherDropdown.classList.remove('show'); + const otherMenu = otherDropdown.querySelector('.dropdown-menu'); + if (otherMenu) otherMenu.classList.remove('show'); + } + }); + + // Toggle current dropdown + const isOpen = dropdown.classList.contains('show'); + if (isOpen) { + dropdown.classList.remove('show'); + menu.classList.remove('show'); + document.body.style.overflow = ''; + document.body.classList.remove('mobile-menu-open'); + } else { + dropdown.classList.add('show'); + menu.classList.add('show'); + document.body.style.overflow = 'hidden'; + document.body.classList.add('mobile-menu-open'); + } + }); + + // Enhanced mobile close button functionality + DOM.on(menu, 'click', (e: Event) => { + const rect = menu.getBoundingClientRect(); + const clickY = (e as MouseEvent).clientY - rect.top; + + // If clicked in top 50px (close button area) + if (clickY <= 50) { + dropdown.classList.remove('show'); + menu.classList.remove('show'); + document.body.style.overflow = ''; + document.body.classList.remove('mobile-menu-open'); + e.preventDefault(); + e.stopPropagation(); + } + }); + } + }); + + // Close dropdowns on escape key + const escapeHandler = (e: Event): void => { + const keyEvent = e as KeyboardEvent; + if (keyEvent.key === 'Escape') { + dropdowns.forEach(dropdown => { + dropdown.classList.remove('show'); + const menu = dropdown.querySelector('.dropdown-menu'); + if (menu) menu.classList.remove('show'); + }); + document.body.style.overflow = ''; + document.body.classList.remove('mobile-menu-open'); + } + }; + + DOM.on(document, 'keydown', escapeHandler); + } + + /** + * Enhanced mobile search handling + */ + private enhanceMobileSearch(): void { + if (!this.options.mobile?.fullWidthSearch) return; + + const searchBox = DOM.select('.search-box') as HTMLElement; + const searchInput = DOM.select('.search-input') as HTMLElement; + + if (searchBox && searchInput) { + const searchToggle = searchBox.querySelector('a') as HTMLAnchorElement; + const searchField = searchInput.querySelector('input') as HTMLInputElement; + + if (searchToggle && searchField) { + // Remove existing listeners to prevent duplication + const newSearchToggle = searchToggle.cloneNode(true) as HTMLAnchorElement; + searchToggle.replaceWith(newSearchToggle); + + DOM.on(newSearchToggle, 'click', (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + + // Close any open dropdowns first + const dropdowns = DOM.selectAll('.nav-right .dropdown'); + dropdowns.forEach(dropdown => { + dropdown.classList.remove('show'); + const menu = dropdown.querySelector('.dropdown-menu'); + if (menu) menu.classList.remove('show'); + }); + + // Toggle search state + const isActive = searchInput.classList.contains('active'); + const searchIcon = newSearchToggle.querySelector('i') as HTMLElement; + + if (isActive) { + this.closeSearch(); + } else { + this.openSearch(searchField, searchIcon); + } + }); + + // Handle search input + DOM.on(searchField, 'keypress', (e: Event) => { + const keyEvent = e as KeyboardEvent; + if (keyEvent.key === 'Enter') { + keyEvent.preventDefault(); + const query = searchField.value.trim(); + if (query) { + this.handleSearch(query); + } + } + }); + } + } + } + + /** + * Open search interface + */ + private openSearch(searchField: HTMLInputElement, searchIcon: HTMLElement): void { + const searchInput = DOM.select('.search-input') as HTMLElement; + + searchInput.classList.add('active'); + document.body.classList.add('search-open'); + + // Change icon to close + if (searchIcon) { + searchIcon.className = 'ti-close'; + } + + // Focus the input after a short delay + setTimeout(() => { + searchField.focus(); + }, 100); + } + + /** + * Close search interface + */ + private closeSearch(): void { + const searchBox = DOM.select('.search-box') as HTMLElement; + const searchInput = DOM.select('.search-input') as HTMLElement; + + if (searchBox && searchInput) { + searchInput.classList.remove('active'); + document.body.classList.remove('search-open'); + document.body.classList.remove('mobile-menu-open'); + + // Reset icon + const searchIcon = searchBox.querySelector('i') as HTMLElement; + if (searchIcon) { + searchIcon.className = 'ti-search'; + } + + // Clear input + const searchField = searchInput.querySelector('input') as HTMLInputElement; + if (searchField) { + searchField.value = ''; + searchField.blur(); + } + } + } + + /** + * Handle search query + */ + private handleSearch(query: string): void { + this.log(`Search query: ${query}`); + + // Implement your search logic here + // For demo, close search after "searching" + this.closeSearch(); + } + + /** + * Add component to the application + */ + public addComponent(name: string, component: ComponentInterface): void { + this.state.components.set(name, component); + this.dispatchEvent('componentAdded', { name, component }); + this.log(`Component added: ${name}`); + } + + /** + * Remove component from the application + */ + public removeComponent(name: string): void { + const component = this.state.components.get(name); + if (component) { + if (typeof component.destroy === 'function') { + component.destroy(); + } + this.state.components.delete(name); + this.dispatchEvent('componentRemoved', { name }); + this.log(`Component removed: ${name}`); + } + } + + /** + * Get a component by name + */ + public getComponent(name: string): ComponentInterface | undefined { + return this.state.components.get(name); + } + + /** + * Get all components + */ + public getComponents(): Map { + return new Map(this.state.components); + } + + /** + * Check if app is ready + */ + public isReady(): boolean { + return this.state.isInitialized; + } + + /** + * Get current application state + */ + public getState(): Readonly { + return { + ...this.state, + components: new Map(this.state.components), + }; + } + + /** + * Update application options + */ + public updateOptions(newOptions: Partial): void { + this.options = { ...this.options, ...newOptions }; + this.log('Options updated'); + } + + /** + * Dispatch custom event + */ + private dispatchEvent( + type: T, + detail: AdminatorAppEvents[T]['detail'] + ): void { + const event = new CustomEvent(`adminator:${type}`, { + detail, + bubbles: true, + }); + window.dispatchEvent(event); + } + + /** + * Log message if debugging is enabled + */ + private log(message: string): void { + if (this.options.debug) { + console.log(`[AdminatorApp] ${message}`); + } + } + + /** + * Destroy the application + */ + public destroy(): void { + this.log('Destroying Adminator App'); + + // Destroy all components + this.state.components.forEach((component, name) => { + if (typeof component.destroy === 'function') { + component.destroy(); + } + this.log(`Component destroyed: ${name}`); + }); + + // Remove event listeners + this.eventHandlers.forEach((_, name) => { + // Note: We'd need to track which element each handler was attached to + // For now, we'll rely on the browser's garbage collection + this.log(`Event handler removed: ${name}`); + }); + + // Clear state + this.state.components.clear(); + this.eventHandlers.clear(); + this.state.isInitialized = false; + + // Clear timeout + if (this.resizeTimeout) { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = null; + } + } + + /** + * Refresh/reinitialize the application + */ + public refresh(): void { + this.log('Refreshing Adminator App'); + + if (this.state.isInitialized) { + this.destroy(); + } + + setTimeout(() => { + this.init(); + }, 100); + } +} + +// Initialize the application +const app = new AdminatorApp({ + debug: process.env.NODE_ENV === 'development', +}); + +// Make app globally available for debugging +window.AdminatorApp = app; + +// Export for module usage +export default app; \ No newline at end of file diff --git a/src/assets/scripts/components/Chart.ts b/src/assets/scripts/components/Chart.ts new file mode 100644 index 0000000..0f6799a --- /dev/null +++ b/src/assets/scripts/components/Chart.ts @@ -0,0 +1,1350 @@ +/** + * Modern Chart Component with TypeScript + * Replaces jQuery Sparkline with Chart.js + */ + +import { Chart, ChartConfiguration, registerables, ChartType as ChartJSType } from 'chart.js'; +import type { ComponentInterface } from '../../../types'; +import { COLORS } from '../constants/colors'; + +// Register Chart.js components +Chart.register(...registerables); + +export interface SparklineConfig { + id: string; + data: number[]; + color: string; +} + +export interface ChartComponentOptions { + enableResize?: boolean; + resizeDebounceMs?: number; + enableAnimation?: boolean; +} + +export interface ChartDimensions { + width: number; + height: number; +} + +export type ChartElementType = 'sparkline' | 'sparkbar' | 'sparktri' | 'sparkdisc' | 'sparkbull' | 'sparkbox' | 'easypie'; + +export class ChartComponent implements ComponentInterface { + public name: string = 'ChartComponent'; + public element: HTMLElement; + public options: ChartComponentOptions; + public isInitialized: boolean = false; + + private charts: Map = new Map(); + private debounceTimer: number | null = null; + + constructor(element?: HTMLElement, options: ChartComponentOptions = {}) { + this.element = element || document.body; + this.options = { + enableResize: true, + resizeDebounceMs: 150, + enableAnimation: true, + ...options, + }; + + this.init(); + } + + /** + * Initialize the chart component + */ + public init(): void { + // Only disable resizing for small sparkline charts + this.createSparklines(); + this.createOtherCharts(); + + if (this.options.enableResize) { + this.setupResizeHandler(); + } + + this.isInitialized = true; + } + + /** + * Destroy the chart component + */ + public destroy(): void { + this.charts.forEach(chart => { + chart.destroy(); + }); + this.charts.clear(); + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + this.isInitialized = false; + } + + /** + * Create sparklines (only for dashboard page) + */ + private createSparklines(): void { + // Only create sparklines if we're on a page that has them + const sparklineExists = document.getElementById('sparklinedash'); + if (!sparklineExists) { + return; + } + + const sparklineConfigs: SparklineConfig[] = [ + { + id: 'sparklinedash', + data: [0, 5, 6, 10, 9, 12, 4, 9], + color: '#4caf50', + }, + { + id: 'sparklinedash2', + data: [0, 5, 6, 10, 9, 12, 4, 9], + color: '#9675ce', + }, + { + id: 'sparklinedash3', + data: [0, 5, 6, 10, 9, 12, 4, 9], + color: '#03a9f3', + }, + { + id: 'sparklinedash4', + data: [0, 5, 6, 10, 9, 12, 4, 9], + color: '#f96262', + }, + ]; + + sparklineConfigs.forEach(config => { + // Only create if the target element exists + if (document.getElementById(config.id)) { + this.createSparklineChart(config); + } + }); + } + + /** + * Create sparkline chart from configuration + */ + private createSparklineChart(config: SparklineConfig): void { + let canvas = document.getElementById(config.id) as HTMLCanvasElement; + + // Only proceed if we have a valid target element + if (!canvas) { + return; + } + + // If element exists but isn't a canvas, replace it with canvas + if (canvas.tagName !== 'CANVAS') { + const parent = canvas.parentNode; + if (!parent) { + return; + } + + // Create new canvas element + const newCanvas = document.createElement('canvas'); + newCanvas.id = config.id; + this.setCanvasDimensions(newCanvas, { width: 100, height: 20 }); + + // Replace the span with canvas + parent.replaceChild(newCanvas, canvas); + canvas = newCanvas; + } else { + // Set canvas dimensions to match original sparkline + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'bar', + data: { + labels: config.data.map((_, i) => i.toString()), + datasets: [{ + data: config.data, + backgroundColor: config.color, + borderColor: config.color, + borderWidth: 0, + barPercentage: 0.6, + categoryPercentage: 0.8, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + animation: this.options.enableAnimation ? {} : false, + events: [], + scales: { + x: { + display: false, + }, + y: { + display: false, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + }, + }, + elements: { + bar: { + borderRadius: 1, + }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(config.id, chart); + } + + /** + * Set canvas dimensions + */ + private setCanvasDimensions(canvas: HTMLCanvasElement, dimensions: ChartDimensions): void { + canvas.width = dimensions.width; + canvas.height = dimensions.height; + canvas.style.width = `${dimensions.width}px`; + canvas.style.height = `${dimensions.height}px`; + } + + /** + * Create other chart types (only if they exist on the page) + */ + private createOtherCharts(): void { + // Determine if we're on the dashboard or charts page + const isChartsPage = document.getElementById('area-chart') !== null; + const isDashboard = !isChartsPage && document.getElementById('line-chart') !== null; + + // Create Monthly Stats chart with enhanced dual-line data (dashboard only) + if (isDashboard) { + this.createMonthlyStatsChart(); + } + + // Charts page specific charts (only on charts page) + if (isChartsPage) { + this.createChartsPageCharts(); + } + + // Only create charts if their target elements exist + if (document.getElementById('sparkline')) { + this.createLineChart('sparkline', [5, 6, 7, 9, 9, 5, 3, 2, 2, 4, 6, 7]); + } + + if (document.getElementById('compositebar')) { + this.createCompositeChart('compositebar', [4, 1, 5, 7, 9, 9, 8, 7, 6, 6, 4, 7, 8, 4, 3, 2, 2, 5, 6, 7]); + } + + // Regular sparklines with custom colors (only on pages that have them) + this.createCustomSparklines(); + + // Easy Pie Charts (only if they exist) + this.createEasyPieCharts(); + } + + /** + * Create enhanced Monthly Stats chart with dual lines and more data + */ + private createMonthlyStatsChart(): void { + const canvas = document.getElementById('line-chart') as HTMLCanvasElement; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Enhanced data for monthly stats + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const salesData = [120, 135, 145, 165, 180, 195, 210, 225, 240, 220, 200, 185]; + const profitData = [45, 52, 58, 62, 68, 75, 82, 88, 92, 85, 78, 72]; + + const chartConfig: ChartConfiguration = { + type: 'line', + data: { + labels: months, + datasets: [ + { + label: 'Sales ($K)', + data: salesData, + borderColor: '#4caf50', + backgroundColor: 'rgba(76, 175, 80, 0.1)', + borderWidth: 3, + pointRadius: 5, + pointHoverRadius: 7, + pointBackgroundColor: '#4caf50', + pointBorderColor: '#ffffff', + pointBorderWidth: 2, + tension: 0.4, + fill: false, + }, + { + label: 'Profit ($K)', + data: profitData, + borderColor: '#2196f3', + backgroundColor: 'rgba(33, 150, 243, 0.1)', + borderWidth: 3, + pointRadius: 5, + pointHoverRadius: 7, + pointBackgroundColor: '#2196f3', + pointBorderColor: '#ffffff', + pointBorderWidth: 2, + tension: 0.4, + fill: false, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + padding: 20, + font: { + size: 12, + weight: 600, + }, + }, + }, + tooltip: { + enabled: true, + cornerRadius: 8, + displayColors: true, + intersect: false, + mode: 'index', + callbacks: { + label(context) { + return `${context.dataset.label}: $${context.parsed.y}K`; + }, + }, + }, + }, + scales: { + x: { + grid: { + display: false, + }, + ticks: { + font: { + size: 11, + }, + }, + }, + y: { + beginAtZero: true, + grid: { + borderDash: [5, 5] as [number, number], + }, + ticks: { + font: { + size: 11, + }, + callback(value) { + return `$${value}K`; + }, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set('line-chart', chart); + } + + /** + * Create line chart (only if target exists) + */ + private createLineChart(id: string, data: number[]): void { + let canvas = document.getElementById(id) as HTMLCanvasElement; + + // Only proceed if target element exists + if (!canvas) { + return; + } + + // If element exists but isn't a canvas, replace it with canvas + if (canvas.tagName !== 'CANVAS') { + const parent = canvas.parentNode; + if (!parent) { + return; + } + + // Create new canvas element + const newCanvas = document.createElement('canvas'); + newCanvas.id = id; + this.setCanvasDimensions(newCanvas, { width: 100, height: 20 }); + + // Replace element with canvas + parent.replaceChild(newCanvas, canvas); + canvas = newCanvas; + } else { + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'line', + data: { + labels: data.map((_, i) => i.toString()), + datasets: [{ + data, + borderColor: COLORS['blue-500'], + backgroundColor: 'transparent', + borderWidth: 1, + pointRadius: 0, + tension: 0.4, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + animation: false, + events: [], + scales: { + x: { display: false }, + y: { display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Create composite chart (only if target exists) + */ + private createCompositeChart(id: string, data: number[]): void { + let canvas = document.getElementById(id) as HTMLCanvasElement; + + // Only proceed if target element exists + if (!canvas) { + return; + } + + // If element exists but isn't a canvas, replace it with canvas + if (canvas.tagName !== 'CANVAS') { + const parent = canvas.parentNode; + if (!parent) { + return; + } + + // Create new canvas element + const newCanvas = document.createElement('canvas'); + newCanvas.id = id; + this.setCanvasDimensions(newCanvas, { width: 100, height: 20 }); + + // Replace element with canvas + parent.replaceChild(newCanvas, canvas); + canvas = newCanvas; + } else { + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'bar', + data: { + labels: data.map((_, i) => i.toString()), + datasets: [ + { + type: 'bar', + data, + backgroundColor: '#aaf', + borderColor: '#aaf', + borderWidth: 0, + }, + { + type: 'line', + data, + borderColor: 'red', + backgroundColor: 'transparent', + borderWidth: 1, + pointRadius: 0, + tension: 0.4, + } as any, // Type assertion needed for mixed chart types + ], + }, + options: { + responsive: false, + maintainAspectRatio: false, + animation: false, + events: [], + scales: { + x: { display: false }, + y: { display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Create custom sparklines for different elements (only if they exist) + */ + private createCustomSparklines(): void { + const sparklineElements = document.querySelectorAll('.sparkline'); + const sparkbarElements = document.querySelectorAll('.sparkbar'); + const sparktriElements = document.querySelectorAll('.sparktri'); + const sparkdiscElements = document.querySelectorAll('.sparkdisc'); + const sparkbullElements = document.querySelectorAll('.sparkbull'); + const sparkboxElements = document.querySelectorAll('.sparkbox'); + + // Only create if we have elements + if (sparklineElements.length === 0 && sparkbarElements.length === 0 && + sparktriElements.length === 0 && sparkdiscElements.length === 0 && + sparkbullElements.length === 0 && sparkboxElements.length === 0) { + return; + } + + const values = [5, 4, 5, -2, 0, 3, -5, 6, 7, 9, 9, 5, -3, -2, 2, -4]; + const valuesAlt = [1, 1, 0, 1, -1, -1, 1, -1, 0, 0, 1, 1]; + + sparklineElements.forEach((element, index) => { + this.createCustomLineChart(element as HTMLElement, values, `sparkline-${index}`); + }); + + sparkbarElements.forEach((element, index) => { + this.createCustomBarChart(element as HTMLElement, values, `sparkbar-${index}`); + }); + + sparktriElements.forEach((element, index) => { + this.createTristateChart(element as HTMLElement, valuesAlt, `sparktri-${index}`); + }); + + sparkdiscElements.forEach((element, index) => { + this.createDiscreteChart(element as HTMLElement, values, `sparkdisc-${index}`); + }); + + sparkbullElements.forEach((element, index) => { + this.createBulletChart(element as HTMLElement, values, `sparkbull-${index}`); + }); + + sparkboxElements.forEach((element, index) => { + this.createBoxChart(element as HTMLElement, values, `sparkbox-${index}`); + }); + } + + /** + * Create custom line chart for sparkline elements + */ + private createCustomLineChart(element: HTMLElement, data: number[], id: string): void { + // Create canvas if it doesn't exist + let canvas = element.querySelector('canvas') as HTMLCanvasElement; + if (!canvas) { + canvas = document.createElement('canvas'); + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + element.appendChild(canvas); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'line', + data: { + labels: data.map((_, i) => i.toString()), + datasets: [{ + data, + borderColor: COLORS['red-500'], + backgroundColor: 'transparent', + borderWidth: 2, + pointRadius: 3, + pointBackgroundColor: COLORS['red-500'], + tension: 0.4, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + animation: false, // Disable animations to prevent resize triggers + events: [], // Disable all events to prevent resize + scales: { + x: { display: false }, + y: { display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, // Disable tooltip to prevent events + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Create custom bar chart for sparkbar elements + */ + private createCustomBarChart(element: HTMLElement, data: number[], id: string): void { + // Create canvas if it doesn't exist + let canvas = element.querySelector('canvas') as HTMLCanvasElement; + if (!canvas) { + canvas = document.createElement('canvas'); + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + element.appendChild(canvas); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'bar', + data: { + labels: data.map((_, i) => i.toString()), + datasets: [{ + data, + backgroundColor: data.map(val => val < 0 ? COLORS['deep-purple-500'] : '#39f'), + borderColor: data.map(val => val < 0 ? COLORS['deep-purple-500'] : '#39f'), + borderWidth: 1, + barPercentage: 0.8, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + scales: { + x: { display: false }, + y: { display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + callbacks: { + label: (context) => `${context.parsed.y}°Celsius`, + }, + }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Setup resize handler for charts + */ + private setupResizeHandler(): void { + // Setup responsive resize for large charts only + window.addEventListener('resize', () => { + this.debounceResize(); + }); + + // Listen for sidebar toggle events + window.addEventListener('sidebar:toggle', () => { + this.debounceResize(); + }); + } + + /** + * Debounced resize handler + */ + private debounceResize(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.redrawLargeChartsOnly(); + }, this.options.resizeDebounceMs || 150); + } + + /** + * Redraw only large charts, not sparklines + */ + private redrawLargeChartsOnly(): void { + const largeChartIds = [ + 'line-chart', 'area-chart', 'scatter-chart', 'bar-chart', + 'doughnut-chart', 'polar-chart', 'radar-chart', 'mixed-chart', 'bubble-chart', + ]; + + largeChartIds.forEach(id => { + const chart = this.charts.get(id); + if (chart && chart.options.responsive) { + chart.resize(); + } + }); + } + + /** + * Redraw all charts (used sparingly) + */ + public redrawCharts(): void { + this.charts.forEach((chart) => { + if (chart.options.responsive) { + chart.resize(); + } + }); + } + + /** + * Update chart data + */ + public updateChart(id: string, newData: number[]): void { + const chart = this.charts.get(id); + if (chart && chart.data.datasets[0]) { + chart.data.datasets[0].data = newData; + chart.update(); + } + } + + /** + * Get chart instance by id + */ + public getChart(id: string): Chart | undefined { + return this.charts.get(id); + } + + /** + * Get all chart instances + */ + public getAllCharts(): Map { + return new Map(this.charts); + } + + /** + * Create charts for the charts.html page + */ + private createChartsPageCharts(): void { + // Line Chart + this.createLargeChart('line-chart', 'line', { + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], + datasets: [{ + label: 'Dataset 1', + data: [65, 59, 80, 81, 56, 55, 40], + borderColor: 'rgb(75, 192, 192)', + backgroundColor: 'rgba(75, 192, 192, 0.2)', + tension: 0.4, + }], + }); + + // Area Chart + this.createLargeChart('area-chart', 'line', { + labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'], + datasets: [{ + label: 'Dataset 1', + data: [65, 59, 80, 81, 56, 55, 40], + borderColor: 'rgb(54, 162, 235)', + backgroundColor: 'rgba(54, 162, 235, 0.4)', + fill: true, + tension: 0.4, + }], + }); + + // Scatter Chart with more data points + this.createLargeChart('scatter-chart', 'scatter', { + datasets: [{ + label: 'Dataset 1', + data: [ + {x: -15, y: 8}, {x: -12, y: 12}, {x: -8, y: 3}, {x: -5, y: 15}, + {x: -2, y: 7}, {x: 0, y: 10}, {x: 3, y: 18}, {x: 6, y: 5}, + {x: 9, y: 22}, {x: 12, y: 8}, {x: 15, y: 14}, {x: 18, y: 19}, + {x: -10, y: 0}, {x: 10, y: 5}, {x: 0.5, y: 5.5}, {x: 7, y: 12}, + {x: -7, y: 17}, {x: 4, y: 9}, {x: 11, y: 16}, {x: -3, y: 11}, + ], + backgroundColor: 'rgba(255, 99, 132, 0.7)', + borderColor: 'rgb(255, 99, 132)', + borderWidth: 1, + }], + }); + + // Continue with other chart types... + this.createBarChart(); + this.createDoughnutChart(); + this.createPolarChart(); + this.createRadarChart(); + this.createMixedChart(); + this.createBubbleChart(); + } + + /** + * Create large chart for charts page + */ + private createLargeChart(id: string, type: ChartJSType, data: any): void { + const canvas = document.getElementById(id) as HTMLCanvasElement; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Define chart-specific options + const chartOptions = this.getChartOptions(type); + + const chartConfig: ChartConfiguration = { + type, + data, + options: chartOptions, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Get chart-specific options based on chart type + */ + private getChartOptions(type: ChartJSType): any { + const baseOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top' as const, + labels: { + padding: 20, + font: { + size: 12, + weight: '600' as const, + }, + }, + }, + tooltip: { + enabled: true, + cornerRadius: 8, + displayColors: true, + }, + }, + }; + + // Chart type specific configurations + switch (type) { + case 'doughnut': + case 'pie': + return { + ...baseOptions, + plugins: { + ...baseOptions.plugins, + legend: { + ...baseOptions.plugins.legend, + position: 'right' as const, + }, + }, + interaction: { + intersect: false, + }, + }; + + case 'polarArea': + return { + ...baseOptions, + scales: { + r: { + pointLabels: { + display: true, + centerPointLabels: true, + font: { + size: 10, + }, + }, + grid: {}, + }, + }, + }; + + case 'radar': + return { + ...baseOptions, + scales: { + r: { + angleLines: { + display: true, + }, + grid: {}, + pointLabels: { + font: { + size: 11, + }, + }, + ticks: { + display: true, + font: { + size: 10, + }, + }, + }, + }, + }; + + case 'bubble': + return { + ...baseOptions, + scales: { + x: { + type: 'linear' as const, + position: 'bottom' as const, + grid: { + borderDash: [5, 5] as [number, number], + }, + ticks: { + font: { + size: 11, + }, + }, + }, + y: { + beginAtZero: true, + grid: { + borderDash: [5, 5] as [number, number], + }, + ticks: { + font: { + size: 11, + }, + }, + }, + }, + }; + + case 'scatter': + return { + ...baseOptions, + scales: { + x: { + type: 'linear' as const, + position: 'bottom' as const, + grid: { + borderDash: [5, 5] as [number, number], + }, + ticks: { + font: { + size: 11, + }, + }, + }, + y: { + grid: { + borderDash: [5, 5] as [number, number], + }, + ticks: { + font: { + size: 11, + }, + }, + }, + }, + }; + + default: + // For line, bar, area, mixed charts + return { + ...baseOptions, + scales: { + x: { + grid: { + borderDash: [5, 5] as [number, number], + }, + ticks: { + font: { + size: 11, + }, + }, + }, + y: { + beginAtZero: true, + grid: { + borderDash: [5, 5] as [number, number], + }, + ticks: { + font: { + size: 11, + }, + }, + }, + }, + }; + } + } + + // Additional chart creation methods... + private createBarChart(): void { + this.createLargeChart('bar-chart', 'bar', { + labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'], + datasets: [{ + label: '# of Votes', + data: [12, 19, 3, 5, 2, 3], + backgroundColor: [ + 'rgba(255, 99, 132, 0.6)', + 'rgba(54, 162, 235, 0.6)', + 'rgba(255, 205, 86, 0.6)', + 'rgba(75, 192, 192, 0.6)', + 'rgba(153, 102, 255, 0.6)', + 'rgba(255, 159, 64, 0.6)', + ], + borderColor: [ + 'rgba(255, 99, 132, 1)', + 'rgba(54, 162, 235, 1)', + 'rgba(255, 205, 86, 1)', + 'rgba(75, 192, 192, 1)', + 'rgba(153, 102, 255, 1)', + 'rgba(255, 159, 64, 1)', + ], + borderWidth: 1, + }], + }); + } + + private createDoughnutChart(): void { + this.createLargeChart('doughnut-chart', 'doughnut', { + labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'], + datasets: [{ + label: 'My First Dataset', + data: [300, 50, 100, 75, 120, 60], + backgroundColor: [ + 'rgba(255, 99, 132, 0.8)', + 'rgba(54, 162, 235, 0.8)', + 'rgba(255, 205, 86, 0.8)', + 'rgba(75, 192, 192, 0.8)', + 'rgba(153, 102, 255, 0.8)', + 'rgba(255, 159, 64, 0.8)', + ], + borderWidth: 2, + hoverOffset: 10, + }], + }); + } + + private createPolarChart(): void { + this.createLargeChart('polar-chart', 'polarArea', { + labels: ['Red', 'Green', 'Yellow', 'Grey', 'Blue'], + datasets: [{ + label: 'My First Dataset', + data: [11, 16, 7, 3, 14], + backgroundColor: [ + 'rgba(255, 99, 132, 0.7)', + 'rgba(75, 192, 192, 0.7)', + 'rgba(255, 205, 86, 0.7)', + 'rgba(201, 203, 207, 0.7)', + 'rgba(54, 162, 235, 0.7)', + ], + borderWidth: 2, + }], + }); + } + + private createRadarChart(): void { + this.createLargeChart('radar-chart', 'radar', { + labels: ['Speed', 'Reliability', 'Comfort', 'Safety', 'Efficiency', 'Innovation'], + datasets: [{ + label: 'Product A', + data: [65, 59, 90, 81, 56, 55], + fill: true, + backgroundColor: 'rgba(54, 162, 235, 0.2)', + borderColor: 'rgb(54, 162, 235)', + borderWidth: 2, + }], + }); + } + + private createMixedChart(): void { + this.createLargeChart('mixed-chart', 'bar', { + labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + datasets: [{ + type: 'bar', + label: 'Sales', + data: [12, 19, 3, 5, 2, 3], + backgroundColor: 'rgba(54, 162, 235, 0.7)', + }, { + type: 'line', + label: 'Revenue', + data: [18, 25, 8, 15, 12, 18], + borderColor: 'rgb(255, 99, 132)', + tension: 0.4, + }], + }); + } + + private createBubbleChart(): void { + this.createLargeChart('bubble-chart', 'bubble', { + datasets: [{ + label: 'First Dataset', + data: [ + {x: 20, y: 30, r: 15}, + {x: 40, y: 10, r: 10}, + {x: 30, y: 40, r: 20}, + ], + backgroundColor: 'rgba(54, 162, 235, 0.6)', + borderColor: 'rgb(54, 162, 235)', + borderWidth: 2, + }], + }); + } + + /** + * Create tristate chart (for .sparktri elements) + */ + private createTristateChart(element: HTMLElement, data: number[], id: string): void { + let canvas = element.querySelector('canvas') as HTMLCanvasElement; + if (!canvas) { + canvas = document.createElement('canvas'); + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + element.appendChild(canvas); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'bar', + data: { + labels: data.map((_, i) => i.toString()), + datasets: [{ + data: data.map(val => Math.abs(val)), + backgroundColor: data.map(val => { + if (val > 0) return COLORS['light-blue-500']; + if (val < 0) return '#f90'; + return '#000'; + }), + borderWidth: 1, + barPercentage: 0.8, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + scales: { + x: { display: false }, + y: { display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Create discrete chart (for .sparkdisc elements) + */ + private createDiscreteChart(element: HTMLElement, data: number[], id: string): void { + let canvas = element.querySelector('canvas') as HTMLCanvasElement; + if (!canvas) { + canvas = document.createElement('canvas'); + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + element.appendChild(canvas); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'scatter', + data: { + datasets: [{ + data: data.map((val, index) => ({x: index, y: val})), + backgroundColor: '#9f0', + borderColor: '#9f0', + pointRadius: 2, + showLine: false, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + scales: { + x: { display: false }, + y: { display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Create bullet chart (for .sparkbull elements) + */ + private createBulletChart(element: HTMLElement, data: number[], id: string): void { + let canvas = element.querySelector('canvas') as HTMLCanvasElement; + if (!canvas) { + canvas = document.createElement('canvas'); + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + element.appendChild(canvas); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'bar', + data: { + labels: [''], + datasets: [{ + data: [Math.max(...data)], + backgroundColor: COLORS['amber-500'], + borderColor: COLORS['amber-500'], + borderWidth: 1, + barPercentage: 0.6, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + indexAxis: 'y', + scales: { + x: { display: false }, + y: { display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Create box chart (for .sparkbox elements) + */ + private createBoxChart(element: HTMLElement, data: number[], id: string): void { + let canvas = element.querySelector('canvas') as HTMLCanvasElement; + if (!canvas) { + canvas = document.createElement('canvas'); + this.setCanvasDimensions(canvas, { width: 100, height: 20 }); + element.appendChild(canvas); + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Box plot simplified as bar chart showing quartiles + const sortedData = [...data].sort((a, b) => a - b); + const q1 = sortedData[Math.floor(sortedData.length * 0.25)]; + const median = sortedData[Math.floor(sortedData.length * 0.5)]; + const q3 = sortedData[Math.floor(sortedData.length * 0.75)]; + + const chartConfig: ChartConfiguration = { + type: 'bar', + data: { + labels: ['Q1', 'Med', 'Q3'], + datasets: [{ + data: [q1, median, q3], + backgroundColor: '#9f0', + borderColor: '#9f0', + borderWidth: 1, + barPercentage: 0.8, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + scales: { + x: { display: false }, + y: { display: false }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(id, chart); + } + + /** + * Create Easy Pie Charts (replaces jQuery Easy Pie Chart) + */ + private createEasyPieCharts(): void { + const easyPieElements = document.querySelectorAll('.easy-pie-chart'); + + easyPieElements.forEach((element, index) => { + const htmlElement = element as HTMLElement; + const size = parseInt(htmlElement.dataset.size || '80'); + const percent = parseInt(htmlElement.dataset.percent || '0'); + const barColor = htmlElement.dataset.barColor || '#f44336'; + + // Create canvas for the pie chart + let canvas = element.querySelector('canvas') as HTMLCanvasElement; + if (!canvas) { + canvas = document.createElement('canvas'); + this.setCanvasDimensions(canvas, { width: size, height: size }); + element.appendChild(canvas); + } + + // Create percentage display + const percentDisplay = element.querySelector('span') as HTMLSpanElement; + if (percentDisplay) { + percentDisplay.textContent = `${percent}%`; + percentDisplay.style.position = 'absolute'; + percentDisplay.style.top = '50%'; + percentDisplay.style.left = '50%'; + percentDisplay.style.transform = 'translate(-50%, -50%)'; + percentDisplay.style.fontSize = '14px'; + percentDisplay.style.fontWeight = 'bold'; + } + + // Set element position to relative for absolute positioning of text + htmlElement.style.position = 'relative'; + htmlElement.style.display = 'inline-block'; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartConfig: ChartConfiguration = { + type: 'doughnut', + data: { + datasets: [{ + data: [percent, 100 - percent], + backgroundColor: [barColor, '#f0f0f0'], + borderWidth: 0, + }], + }, + options: { + responsive: false, + maintainAspectRatio: false, + cutout: '70%', + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }, + }; + + const chart = new Chart(ctx, chartConfig); + this.charts.set(`easy-pie-${index}`, chart); + }); + } +} + +export default ChartComponent; \ No newline at end of file diff --git a/src/assets/scripts/components/Sidebar.ts b/src/assets/scripts/components/Sidebar.ts new file mode 100644 index 0000000..ca9da26 --- /dev/null +++ b/src/assets/scripts/components/Sidebar.ts @@ -0,0 +1,388 @@ +/** + * Modern Sidebar Component with TypeScript + * Replaces jQuery-based sidebar functionality with vanilla JavaScript + */ + +import type { ComponentInterface, SidebarOptions, SidebarState, AnimationOptions } from '../../../types'; + +export interface SidebarEventDetail { + collapsed: boolean; +} + +export interface SidebarToggleEvent extends CustomEvent { + detail: SidebarEventDetail; +} + +declare global { + interface Window { + EVENT?: Event; + } +} + +export class Sidebar implements ComponentInterface { + public name: string = 'Sidebar'; + public element: HTMLElement; + public options: SidebarOptions; + public isInitialized: boolean = false; + + private sidebar: HTMLElement | null; + private sidebarMenu: HTMLElement | null; + private sidebarToggleLinks: NodeListOf; + private sidebarToggleById: HTMLElement | null; + private app: HTMLElement | null; + private state: SidebarState; + + constructor(element?: HTMLElement, options: SidebarOptions = {}) { + this.element = element || document.body; + this.options = { + breakpoint: 768, + collapsible: true, + autoHide: true, + animation: true, + animationDuration: 200, + ...options, + }; + + this.sidebar = document.querySelector('.sidebar'); + this.sidebarMenu = document.querySelector('.sidebar .sidebar-menu'); + this.sidebarToggleLinks = document.querySelectorAll('.sidebar-toggle a'); + this.sidebarToggleById = document.querySelector('#sidebar-toggle'); + this.app = document.querySelector('.app'); + + this.state = { + isCollapsed: false, + isMobile: false, + activeMenu: null, + }; + + this.init(); + } + + /** + * Initialize the sidebar component + */ + public init(): void { + if (!this.sidebar || !this.sidebarMenu) { + console.warn('Sidebar: Required elements not found'); + return; + } + + this.setupMenuToggle(); + this.setupSidebarToggle(); + this.setActiveLink(); + this.handleResize(); + this.setupEventListeners(); + + this.isInitialized = true; + } + + /** + * Destroy the sidebar component + */ + public destroy(): void { + this.removeEventListeners(); + this.isInitialized = false; + } + + /** + * Setup dropdown menu functionality + */ + private setupMenuToggle(): void { + if (!this.sidebarMenu) return; + + const menuLinks = this.sidebarMenu.querySelectorAll('li a'); + + menuLinks.forEach(link => { + link.addEventListener('click', this.handleMenuClick.bind(this)); + }); + } + + /** + * Handle menu item click + */ + private handleMenuClick(e: Event): void { + const link = e.target as HTMLAnchorElement; + const listItem = link.parentElement as HTMLLIElement; + const dropdownMenu = listItem?.querySelector('.dropdown-menu') as HTMLElement; + + // If this is a regular navigation link (not dropdown), allow normal navigation + if (!dropdownMenu) { + return; + } + + // Only prevent default for dropdown toggles + e.preventDefault(); + + if (listItem.classList.contains('open')) { + this.closeDropdown(listItem, dropdownMenu); + } else { + this.closeAllDropdowns(); + this.openDropdown(listItem, dropdownMenu); + } + } + + /** + * Open dropdown with smooth animation + */ + private openDropdown(listItem: HTMLLIElement, dropdownMenu: HTMLElement): void { + listItem.classList.add('open'); + dropdownMenu.style.display = 'block'; + dropdownMenu.style.height = '0px'; + dropdownMenu.style.overflow = 'hidden'; + + // Get the natural height + const height = dropdownMenu.scrollHeight; + + // Animate to full height + const animation = dropdownMenu.animate([ + { height: '0px' }, + { height: `${height}px` }, + ], { + duration: this.options.animationDuration, + easing: 'ease-out', + }); + + animation.onfinish = (): void => { + dropdownMenu.style.height = 'auto'; + dropdownMenu.style.overflow = 'visible'; + }; + } + + /** + * Close dropdown with smooth animation + */ + private closeDropdown(listItem: HTMLLIElement, dropdownMenu: HTMLElement): void { + const height = dropdownMenu.scrollHeight; + + dropdownMenu.style.height = `${height}px`; + dropdownMenu.style.overflow = 'hidden'; + + const animation = dropdownMenu.animate([ + { height: `${height}px` }, + { height: '0px' }, + ], { + duration: this.options.animationDuration, + easing: 'ease-in', + }); + + animation.onfinish = (): void => { + listItem.classList.remove('open'); + dropdownMenu.style.display = 'none'; + dropdownMenu.style.height = ''; + dropdownMenu.style.overflow = ''; + }; + } + + /** + * Close all open dropdowns + */ + private closeAllDropdowns(): void { + if (!this.sidebarMenu) return; + + const openItems = this.sidebarMenu.querySelectorAll('li.open'); + + openItems.forEach(item => { + const dropdownMenu = item.querySelector('.dropdown-menu') as HTMLElement; + if (dropdownMenu) { + this.closeDropdown(item as HTMLLIElement, dropdownMenu); + } + + // Also remove the has-active-child class + item.classList.remove('has-active-child'); + }); + } + + /** + * Setup sidebar toggle functionality + */ + private setupSidebarToggle(): void { + // Handle mobile sidebar toggle links (inside .sidebar-toggle divs) + this.sidebarToggleLinks.forEach(link => { + if (link && this.app) { + link.addEventListener('click', this.handleSidebarToggle.bind(this)); + } + }); + + // Handle the main topbar sidebar toggle + if (this.sidebarToggleById && this.app) { + this.sidebarToggleById.addEventListener('click', this.handleSidebarToggle.bind(this)); + } + } + + /** + * Handle sidebar toggle click + */ + private handleSidebarToggle(e: Event): void { + e.preventDefault(); + this.toggleSidebar(); + } + + /** + * Toggle sidebar and handle resize events properly + */ + private toggleSidebar(): void { + if (!this.app) return; + + const wasCollapsed = this.state.isCollapsed; + this.state.isCollapsed = !wasCollapsed; + + this.app.classList.toggle('is-collapsed'); + + // Dispatch custom event with proper typing + setTimeout(() => { + const event: SidebarToggleEvent = new CustomEvent('sidebar:toggle', { + detail: { collapsed: this.state.isCollapsed }, + }) as SidebarToggleEvent; + + window.dispatchEvent(event); + + // Still trigger resize for masonry but with a specific check + if (window.EVENT) { + window.dispatchEvent(window.EVENT); + } + }, this.options.animationDuration || 300); + } + + /** + * Set active link based on current URL + */ + private setActiveLink(): void { + if (!this.sidebar) return; + + // Remove active class from all nav items (including dropdown items) + const allNavItems = this.sidebar.querySelectorAll('.nav-item'); + allNavItems.forEach(item => { + item.classList.remove('actived'); + }); + + // Close all dropdowns first + this.closeAllDropdowns(); + + // Get current page filename + const currentPath = window.location.pathname; + const currentPage = currentPath.split('/').pop() || 'index.html'; + + // Find and activate the correct nav item + const allLinks = this.sidebar.querySelectorAll('a[href]'); + + allLinks.forEach(link => { + const href = link.getAttribute('href'); + if (!href || href === 'javascript:void(0);' || href === 'javascript:void(0)') return; + + // Extract filename from href + const linkPage = href.split('/').pop(); + + if (linkPage === currentPage) { + const navItem = link.closest('.nav-item') as HTMLElement; + if (navItem) { + navItem.classList.add('actived'); + this.state.activeMenu = linkPage || null; + + // If this is inside a dropdown, handle parent dropdown specially + const parentDropdown = navItem.closest('.dropdown-menu') as HTMLElement; + if (parentDropdown) { + const parentDropdownItem = parentDropdown.closest('.nav-item.dropdown') as HTMLElement; + if (parentDropdownItem) { + // Open the parent dropdown + parentDropdownItem.classList.add('open'); + parentDropdown.style.display = 'block'; + + // Add special styling to indicate parent has active child + parentDropdownItem.classList.add('has-active-child'); + } + } + } + } + }); + } + + /** + * Handle window resize + */ + private handleResize(): void { + this.state.isMobile = window.innerWidth <= (this.options.breakpoint || 768); + + if (this.options.autoHide && this.state.isMobile) { + // Auto-hide logic for mobile + this.collapse(); + } + } + + /** + * Setup event listeners + */ + private setupEventListeners(): void { + window.addEventListener('resize', this.handleResize.bind(this)); + } + + /** + * Remove event listeners + */ + private removeEventListeners(): void { + window.removeEventListener('resize', this.handleResize.bind(this)); + } + + /** + * Public method to refresh active links (useful for SPA navigation) + */ + public refreshActiveLink(): void { + this.setActiveLink(); + } + + /** + * Public method to toggle sidebar programmatically + */ + public toggle(): void { + this.toggleSidebar(); + } + + /** + * Public method to collapse sidebar + */ + public collapse(): void { + if (!this.app || this.state.isCollapsed) return; + + this.state.isCollapsed = true; + this.app.classList.add('is-collapsed'); + } + + /** + * Public method to expand sidebar + */ + public expand(): void { + if (!this.app || !this.state.isCollapsed) return; + + this.state.isCollapsed = false; + this.app.classList.remove('is-collapsed'); + } + + /** + * Public method to check if sidebar is collapsed + */ + public isCollapsed(): boolean { + return this.state.isCollapsed; + } + + /** + * Get current sidebar state + */ + public getState(): SidebarState { + return { ...this.state }; + } + + /** + * Update sidebar options + */ + public updateOptions(newOptions: Partial): void { + this.options = { ...this.options, ...newOptions }; + } + + /** + * Get current options + */ + public getOptions(): SidebarOptions { + return { ...this.options }; + } +} + +export default Sidebar; \ No newline at end of file diff --git a/src/assets/scripts/datatable/index.js b/src/assets/scripts/datatable/index.js index 85ca1d9..4b38ff6 100755 --- a/src/assets/scripts/datatable/index.js +++ b/src/assets/scripts/datatable/index.js @@ -1,4 +1,4 @@ -import Theme from '../utils/theme.js'; +// DataTable implementation export default (function () { diff --git a/src/assets/scripts/datatable/index.ts b/src/assets/scripts/datatable/index.ts new file mode 100644 index 0000000..3c5c3dc --- /dev/null +++ b/src/assets/scripts/datatable/index.ts @@ -0,0 +1,707 @@ +/** + * DataTable Implementation with TypeScript + * Vanilla JavaScript DataTable with sorting, searching, and pagination + */ + +import type { ComponentInterface } from '../../types'; + +// Type definitions for DataTable +export interface DataTableOptions { + sortable?: boolean; + searchable?: boolean; + pagination?: boolean; + pageSize?: number; + responsive?: boolean; + striped?: boolean; + bordered?: boolean; + hover?: boolean; +} + +export interface DataTableColumn { + title: string; + data: string | number; + sortable?: boolean; + searchable?: boolean; + width?: string; + className?: string; + render?: (data: any, row: any[], index: number) => string; +} + +export interface DataTableData { + columns: DataTableColumn[]; + rows: any[][]; +} + +export interface DataTableState { + currentPage: number; + sortColumn: number | null; + sortDirection: 'asc' | 'desc'; + searchQuery: string; + filteredData: any[][]; + totalPages: number; +} + +export type SortDirection = 'asc' | 'desc'; + +declare global { + interface HTMLTableElement { + dataTableInstance?: VanillaDataTable; + } +} + +// Enhanced DataTable implementation +export class VanillaDataTable implements ComponentInterface { + public name: string = 'VanillaDataTable'; + public element: HTMLTableElement; + public options: DataTableOptions; + public isInitialized: boolean = false; + + private originalData: any[][] = []; + private filteredData: any[][] = []; + private state: DataTableState; + private wrapper: HTMLElement | null = null; + private searchInput: HTMLInputElement | null = null; + private infoElement: HTMLElement | null = null; + private paginationElement: HTMLElement | null = null; + + constructor(element: HTMLTableElement, options: DataTableOptions = {}) { + this.element = element; + this.options = { + sortable: true, + searchable: true, + pagination: true, + pageSize: 10, + responsive: true, + striped: true, + bordered: true, + hover: true, + ...options, + }; + + this.state = { + currentPage: 1, + sortColumn: null, + sortDirection: 'asc', + searchQuery: '', + filteredData: [], + totalPages: 0, + }; + + this.init(); + } + + public init(): void { + this.extractData(); + this.createControls(); + this.applyStyles(); + this.bindEvents(); + this.render(); + this.isInitialized = true; + } + + public destroy(): void { + if (this.wrapper && this.wrapper.parentNode) { + this.wrapper.parentNode.replaceChild(this.element, this.wrapper); + } + this.isInitialized = false; + } + + private extractData(): void { + const tbody = this.element.querySelector('tbody'); + if (!tbody) return; + + const rows = tbody.querySelectorAll('tr'); + this.originalData = Array.from(rows).map(row => { + const cells = row.querySelectorAll('td'); + return Array.from(cells).map(cell => cell.textContent?.trim() || ''); + }); + this.filteredData = [...this.originalData]; + this.state.filteredData = this.filteredData; + } + + private createControls(): void { + const wrapper = document.createElement('div'); + wrapper.className = 'datatable-wrapper'; + + // Create top controls container + const topControls = document.createElement('div'); + topControls.className = 'datatable-top-controls'; + + // Create search input + if (this.options.searchable) { + const searchWrapper = document.createElement('div'); + searchWrapper.className = 'datatable-search'; + + const searchLabel = document.createElement('label'); + searchLabel.textContent = 'Search: '; + + this.searchInput = document.createElement('input'); + this.searchInput.type = 'text'; + this.searchInput.className = 'form-control'; + this.searchInput.placeholder = 'Search...'; + + searchLabel.appendChild(this.searchInput); + searchWrapper.appendChild(searchLabel); + topControls.appendChild(searchWrapper); + } + + // Create info display + if (this.options.pagination) { + this.infoElement = document.createElement('div'); + this.infoElement.className = 'datatable-info'; + topControls.appendChild(this.infoElement); + } + + wrapper.appendChild(topControls); + + // Wrap the table + if (this.element.parentNode) { + this.element.parentNode.insertBefore(wrapper, this.element); + } + wrapper.appendChild(this.element); + + // Create pagination controls + if (this.options.pagination) { + this.paginationElement = document.createElement('div'); + this.paginationElement.className = 'datatable-pagination'; + wrapper.appendChild(this.paginationElement); + } + + this.wrapper = wrapper; + } + + private applyStyles(): void { + // Apply Bootstrap-like styles + const classes = ['table']; + if (this.options.striped) classes.push('table-striped'); + if (this.options.bordered) classes.push('table-bordered'); + if (this.options.hover) classes.push('table-hover'); + if (this.options.responsive) { + const responsiveWrapper = document.createElement('div'); + responsiveWrapper.className = 'table-responsive'; + if (this.element.parentNode) { + this.element.parentNode.insertBefore(responsiveWrapper, this.element); + responsiveWrapper.appendChild(this.element); + } + } + + this.element.className = classes.join(' '); + + // Add custom styles + this.injectStyles(); + } + + private injectStyles(): void { + const styleId = 'datatable-styles'; + if (document.getElementById(styleId)) return; + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .datatable-wrapper { + margin: 20px 0; + } + + .datatable-top-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + flex-wrap: wrap; + gap: 10px; + } + + .datatable-search { + display: flex; + align-items: center; + gap: 8px; + } + + .datatable-search label { + margin: 0; + font-weight: 500; + } + + .datatable-search input { + width: 250px; + padding: 6px 12px; + border: 1px solid var(--c-border, #dee2e6); + border-radius: 4px; + font-size: 14px; + } + + .datatable-info { + color: var(--c-text-muted, #6c757d); + font-size: 14px; + margin: 0; + } + + .datatable-pagination { + margin-top: 15px; + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + flex-wrap: wrap; + } + + .datatable-pagination button { + background: var(--c-bkg-card, #fff); + border: 1px solid var(--c-border, #dee2e6); + color: var(--c-text-base, #333); + padding: 8px 12px; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + transition: all 0.2s ease; + min-width: 40px; + } + + .datatable-pagination button:hover:not(:disabled) { + background: var(--c-primary, #007bff); + border-color: var(--c-primary, #007bff); + color: white; + } + + .datatable-pagination button.active { + background: var(--c-primary, #007bff); + border-color: var(--c-primary, #007bff); + color: white; + } + + .datatable-pagination button:disabled { + opacity: 0.6; + cursor: not-allowed; + background: var(--c-bkg-muted, #f8f9fa); + } + + .datatable-sort { + cursor: pointer; + user-select: none; + position: relative; + padding-right: 20px !important; + transition: background-color 0.2s ease; + } + + .datatable-sort:hover { + background: var(--c-bkg-hover, #f8f9fa); + } + + .datatable-sort::after { + content: '↕'; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + opacity: 0.5; + font-size: 12px; + } + + .datatable-sort.asc::after { + content: '↑'; + opacity: 1; + color: var(--c-primary, #007bff); + } + + .datatable-sort.desc::after { + content: '↓'; + opacity: 1; + color: var(--c-primary, #007bff); + } + + .datatable-no-results { + text-align: center; + color: var(--c-text-muted, #6c757d); + font-style: italic; + padding: 20px; + } + + @media (max-width: 768px) { + .datatable-top-controls { + flex-direction: column; + align-items: stretch; + } + + .datatable-search input { + width: 100%; + } + + .datatable-pagination { + justify-content: center; + } + + .datatable-pagination button { + padding: 6px 10px; + font-size: 13px; + } + } + `; + document.head.appendChild(style); + } + + private bindEvents(): void { + // Search functionality + if (this.options.searchable && this.searchInput) { + this.searchInput.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + this.search(target.value); + }); + } + + // Sorting functionality + if (this.options.sortable) { + const headers = this.element.querySelectorAll('thead th'); + headers.forEach((header, index) => { + header.classList.add('datatable-sort'); + header.addEventListener('click', () => { + this.sort(index); + }); + header.setAttribute('tabindex', '0'); + header.setAttribute('role', 'button'); + header.setAttribute('aria-label', `Sort by ${header.textContent}`); + }); + } + } + + public search(query: string): void { + this.state.searchQuery = query; + + if (!query.trim()) { + this.filteredData = [...this.originalData]; + } else { + const searchTerm = query.toLowerCase().trim(); + this.filteredData = this.originalData.filter(row => + row.some(cell => + cell.toString().toLowerCase().includes(searchTerm) + ) + ); + } + + this.state.filteredData = this.filteredData; + this.state.currentPage = 1; + this.render(); + } + + public sort(columnIndex: number): void { + if (this.state.sortColumn === columnIndex) { + this.state.sortDirection = this.state.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + this.state.sortColumn = columnIndex; + this.state.sortDirection = 'asc'; + } + + this.filteredData.sort((a, b) => { + const aVal = a[columnIndex]; + const bVal = b[columnIndex]; + + // Try to parse as numbers + const aNum = parseFloat(aVal); + const bNum = parseFloat(bVal); + + let comparison = 0; + if (!isNaN(aNum) && !isNaN(bNum)) { + comparison = aNum - bNum; + } else { + // Try to parse as dates + const aDate = new Date(aVal); + const bDate = new Date(bVal); + + if (aDate.getTime() && bDate.getTime()) { + comparison = aDate.getTime() - bDate.getTime(); + } else { + comparison = aVal.toString().localeCompare(bVal.toString()); + } + } + + return this.state.sortDirection === 'asc' ? comparison : -comparison; + }); + + this.updateSortHeaders(); + this.render(); + } + + private updateSortHeaders(): void { + const headers = this.element.querySelectorAll('thead th'); + headers.forEach((header, index) => { + header.classList.remove('asc', 'desc'); + if (index === this.state.sortColumn) { + header.classList.add(this.state.sortDirection); + } + }); + } + + public render(): void { + const tbody = this.element.querySelector('tbody'); + if (!tbody) return; + + const startIndex = (this.state.currentPage - 1) * this.options.pageSize!; + const endIndex = startIndex + this.options.pageSize!; + const pageData = this.filteredData.slice(startIndex, endIndex); + + // Clear tbody + tbody.innerHTML = ''; + + if (pageData.length === 0) { + // Show no results message + const noResultsRow = document.createElement('tr'); + const noResultsCell = document.createElement('td'); + noResultsCell.colSpan = this.getColumnCount(); + noResultsCell.className = 'datatable-no-results'; + noResultsCell.textContent = this.state.searchQuery ? + 'No matching records found' : 'No data available'; + noResultsRow.appendChild(noResultsCell); + tbody.appendChild(noResultsRow); + } else { + // Add rows + pageData.forEach((rowData, rowIndex) => { + const row = document.createElement('tr'); + rowData.forEach((cellData, colIndex) => { + const cell = document.createElement('td'); + cell.textContent = cellData.toString(); + row.appendChild(cell); + }); + tbody.appendChild(row); + }); + } + + // Update pagination + if (this.options.pagination) { + this.updatePagination(); + } + + // Update info + this.updateInfo(); + } + + private getColumnCount(): number { + const headerRow = this.element.querySelector('thead tr'); + return headerRow ? headerRow.querySelectorAll('th').length : 0; + } + + private updatePagination(): void { + if (!this.paginationElement) return; + + this.state.totalPages = Math.ceil(this.filteredData.length / this.options.pageSize!); + this.paginationElement.innerHTML = ''; + + if (this.state.totalPages <= 1) return; + + // Previous button + const prevBtn = this.createPaginationButton('Previous', () => { + if (this.state.currentPage > 1) { + this.state.currentPage--; + this.render(); + } + }); + prevBtn.disabled = this.state.currentPage === 1; + this.paginationElement.appendChild(prevBtn); + + // Calculate page range to show + const maxButtons = 5; + let startPage = Math.max(1, this.state.currentPage - Math.floor(maxButtons / 2)); + let endPage = Math.min(this.state.totalPages, startPage + maxButtons - 1); + + // Adjust if we're at the end + if (endPage - startPage + 1 < maxButtons) { + startPage = Math.max(1, endPage - maxButtons + 1); + } + + // First page if not in range + if (startPage > 1) { + const firstBtn = this.createPaginationButton('1', () => { + this.state.currentPage = 1; + this.render(); + }); + this.paginationElement.appendChild(firstBtn); + + if (startPage > 2) { + const ellipsis = document.createElement('span'); + ellipsis.textContent = '...'; + ellipsis.className = 'pagination-ellipsis'; + this.paginationElement.appendChild(ellipsis); + } + } + + // Page numbers + for (let i = startPage; i <= endPage; i++) { + const pageBtn = this.createPaginationButton(i.toString(), () => { + this.state.currentPage = i; + this.render(); + }); + pageBtn.classList.toggle('active', i === this.state.currentPage); + this.paginationElement.appendChild(pageBtn); + } + + // Last page if not in range + if (endPage < this.state.totalPages) { + if (endPage < this.state.totalPages - 1) { + const ellipsis = document.createElement('span'); + ellipsis.textContent = '...'; + ellipsis.className = 'pagination-ellipsis'; + this.paginationElement.appendChild(ellipsis); + } + + const lastBtn = this.createPaginationButton(this.state.totalPages.toString(), () => { + this.state.currentPage = this.state.totalPages; + this.render(); + }); + this.paginationElement.appendChild(lastBtn); + } + + // Next button + const nextBtn = this.createPaginationButton('Next', () => { + if (this.state.currentPage < this.state.totalPages) { + this.state.currentPage++; + this.render(); + } + }); + nextBtn.disabled = this.state.currentPage === this.state.totalPages; + this.paginationElement.appendChild(nextBtn); + } + + private createPaginationButton(text: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement('button'); + button.textContent = text; + button.addEventListener('click', onClick); + return button; + } + + private updateInfo(): void { + if (!this.infoElement) return; + + const startIndex = (this.state.currentPage - 1) * this.options.pageSize! + 1; + const endIndex = Math.min(startIndex + this.options.pageSize! - 1, this.filteredData.length); + const total = this.filteredData.length; + const originalTotal = this.originalData.length; + + if (total === 0) { + this.infoElement.textContent = 'No entries to show'; + } else if (total === originalTotal) { + this.infoElement.textContent = `Showing ${startIndex} to ${endIndex} of ${total} entries`; + } else { + this.infoElement.textContent = `Showing ${startIndex} to ${endIndex} of ${total} entries (filtered from ${originalTotal} total entries)`; + } + } + + // Public API methods + public goToPage(page: number): void { + if (page >= 1 && page <= this.state.totalPages) { + this.state.currentPage = page; + this.render(); + } + } + + public setPageSize(size: number): void { + this.options.pageSize = size; + this.state.currentPage = 1; + this.render(); + } + + public getState(): Readonly { + return { ...this.state }; + } + + public refresh(): void { + this.extractData(); + this.state.currentPage = 1; + this.render(); + } + + public clear(): void { + this.originalData = []; + this.filteredData = []; + this.state.currentPage = 1; + this.render(); + } +} + +// DataTable Manager +export class DataTableManager { + private instances: Map = new Map(); + + public initialize(selector: string = '#dataTable', options: DataTableOptions = {}): VanillaDataTable | null { + const element = document.querySelector(selector); + if (!element) { + // Silently return null if element doesn't exist (normal for pages without tables) + return null; + } + + // Clean up existing instance + if (element.dataTableInstance) { + element.dataTableInstance.destroy(); + } + + // Create new instance + const dataTable = new VanillaDataTable(element, options); + element.dataTableInstance = dataTable; + + // Store in manager + this.instances.set(selector, dataTable); + + return dataTable; + } + + public getInstance(selector: string): VanillaDataTable | undefined { + return this.instances.get(selector); + } + + public destroyInstance(selector: string): void { + const instance = this.instances.get(selector); + if (instance) { + instance.destroy(); + this.instances.delete(selector); + } + } + + public destroyAll(): void { + this.instances.forEach((instance, selector) => { + instance.destroy(); + }); + this.instances.clear(); + } +} + +// Create singleton manager +const dataTableManager = new DataTableManager(); + +// Initialize DataTable +const initializeDataTable = (): void => { + // Only initialize if the table exists + if (document.querySelector('#dataTable')) { + dataTableManager.initialize('#dataTable', { + sortable: true, + searchable: true, + pagination: true, + pageSize: 10, + responsive: true, + striped: true, + bordered: true, + hover: true, + }); + } +}; + +// Initialize on load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeDataTable); +} else { + initializeDataTable(); +} + +// Reinitialize on theme change +window.addEventListener('adminator:themeChanged', () => { + setTimeout(initializeDataTable, 100); +}); + +// Cleanup on page unload +window.addEventListener('beforeunload', () => { + dataTableManager.destroyAll(); +}); + +// Export default for compatibility +export default { + init: initializeDataTable, + manager: dataTableManager, + VanillaDataTable, + DataTableManager, +}; \ No newline at end of file diff --git a/src/assets/scripts/datepicker/index.js b/src/assets/scripts/datepicker/index.js index 143d527..5031d9b 100755 --- a/src/assets/scripts/datepicker/index.js +++ b/src/assets/scripts/datepicker/index.js @@ -1,5 +1,4 @@ import DateUtils from '../utils/date.js'; -import Theme from '../utils/theme.js'; export default (function () { @@ -163,7 +162,7 @@ export default (function () { bindEvents() { // Handle click events - this.element.addEventListener('click', (e) => { + this.element.addEventListener('click', () => { this.openPicker(); }); @@ -180,7 +179,7 @@ export default (function () { }); // Handle focus events - this.element.addEventListener('focus', (e) => { + this.element.addEventListener('focus', () => { this.element.classList.add('datepicker-animation'); setTimeout(() => { this.element.classList.remove('datepicker-animation'); @@ -195,7 +194,7 @@ export default (function () { if (this.element.showPicker && typeof this.element.showPicker === 'function') { try { this.element.showPicker(); - } catch (e) { + } catch { // Fallback for browsers that don't support showPicker } } diff --git a/src/assets/scripts/datepicker/index.ts b/src/assets/scripts/datepicker/index.ts new file mode 100644 index 0000000..85fd091 --- /dev/null +++ b/src/assets/scripts/datepicker/index.ts @@ -0,0 +1,699 @@ +/** + * Enhanced HTML5 DatePicker with TypeScript + * Modern date picker implementation using native HTML5 input[type="date"] + */ + +import DateUtils from '../utils/date'; +import type { ComponentInterface } from '../../types'; + +// Type definitions for DatePicker +export interface DatePickerOptions { + format?: string; + autoclose?: boolean; + todayHighlight?: boolean; + minDate?: string; + maxDate?: string; + startDate?: string; + endDate?: string; + daysOfWeekDisabled?: number[]; + datesDisabled?: string[]; + weekStart?: number; + language?: string; +} + +export interface DatePickerEvent { + date: string; + formattedDate: string; + dateObject: Date; + isValid: boolean; +} + +export interface DatePickerValidation { + isValid: boolean; + errors: string[]; +} + +declare global { + interface HTMLInputElement { + vanillaDatePicker?: VanillaDatePicker; + showPicker?: () => void; + } +} + +// Enhanced HTML5 date picker with vanilla JS +export class VanillaDatePicker implements ComponentInterface { + public name: string = 'VanillaDatePicker'; + public element: HTMLInputElement; + public options: DatePickerOptions; + public isInitialized: boolean = false; + + private wrapper: HTMLElement | null = null; + private todayIndicator: HTMLElement | null = null; + private validationErrors: string[] = []; + + constructor(element: HTMLInputElement, options: DatePickerOptions = {}) { + this.element = element; + this.options = { + format: 'yyyy-mm-dd', + autoclose: true, + todayHighlight: true, + weekStart: 0, + language: 'en', + ...options, + }; + + this.init(); + } + + public init(): void { + this.convertToHTML5(); + this.enhanceInput(); + this.applyStyles(); + this.bindEvents(); + this.validateConstraints(); + this.addTodayHighlight(); + this.isInitialized = true; + } + + public destroy(): void { + if (this.wrapper && this.wrapper.parentNode) { + this.wrapper.parentNode.replaceChild(this.element, this.wrapper); + } + this.isInitialized = false; + } + + private convertToHTML5(): void { + // Convert input to HTML5 date type + this.element.type = 'date'; + this.element.classList.add('form-control', 'vanilla-datepicker'); + + // Remove placeholder since HTML5 date inputs don't need it + this.element.removeAttribute('placeholder'); + + // Set constraints + if (this.options.minDate) { + this.element.min = this.options.minDate; + } + if (this.options.maxDate) { + this.element.max = this.options.maxDate; + } + + // Set default value if no value is set + if (!this.element.value) { + if (this.options.startDate) { + this.element.value = this.options.startDate; + } else if (this.options.todayHighlight) { + this.element.value = DateUtils.formatters.inputDate(DateUtils.now()); + } + } + + // Ensure proper styling + this.element.style.minHeight = '38px'; + this.element.style.lineHeight = '1.5'; + this.element.style.cursor = 'pointer'; + + // Add ARIA attributes + this.element.setAttribute('aria-label', 'Select date'); + this.element.setAttribute('role', 'textbox'); + this.element.setAttribute('aria-expanded', 'false'); + } + + private enhanceInput(): void { + // Create wrapper for enhanced functionality + const wrapper = document.createElement('div'); + wrapper.className = 'vanilla-datepicker-wrapper'; + wrapper.style.position = 'relative'; + + // Wrap the input + const parent = this.element.parentNode; + if (parent) { + parent.insertBefore(wrapper, this.element); + wrapper.appendChild(this.element); + } + + // Add calendar icon if input is in an input group + const inputGroup = this.element.closest('.input-group'); + if (inputGroup) { + const calendarIcon = inputGroup.querySelector('.input-group-text i.ti-calendar'); + if (calendarIcon) { + calendarIcon.addEventListener('click', this.handleIconClick.bind(this)); + calendarIcon.style.cursor = 'pointer'; + calendarIcon.setAttribute('tabindex', '0'); + calendarIcon.setAttribute('role', 'button'); + calendarIcon.setAttribute('aria-label', 'Open calendar'); + } + } + + this.wrapper = wrapper; + } + + private applyStyles(): void { + const styleId = 'vanilla-datepicker-styles'; + if (document.getElementById(styleId)) return; + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .vanilla-datepicker-wrapper { + position: relative; + display: inline-block; + width: 100%; + } + + .vanilla-datepicker { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--c-border, #ced4da); + border-radius: 4px; + background-color: var(--c-bkg-card, #fff); + color: var(--c-text-base, #495057); + font-size: 14px; + font-family: inherit; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + } + + .vanilla-datepicker:focus { + outline: none; + border-color: var(--c-primary, #007bff); + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + } + + .vanilla-datepicker:invalid { + border-color: var(--c-danger, #dc3545); + } + + .vanilla-datepicker:invalid:focus { + border-color: var(--c-danger, #dc3545); + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); + } + + .vanilla-datepicker::-webkit-calendar-picker-indicator { + cursor: pointer; + border-radius: 4px; + margin-right: 2px; + opacity: 0.6; + transition: opacity 0.15s ease-in-out; + filter: var(--datepicker-icon-filter, none); + } + + .vanilla-datepicker::-webkit-calendar-picker-indicator:hover { + opacity: 1; + } + + .vanilla-datepicker::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + + .vanilla-datepicker::-webkit-datetime-edit-month-field, + .vanilla-datepicker::-webkit-datetime-edit-day-field, + .vanilla-datepicker::-webkit-datetime-edit-year-field { + color: var(--c-text-base, #495057); + } + + .vanilla-datepicker::-webkit-datetime-edit-text { + color: var(--c-text-muted, #6c757d); + } + + /* Dark mode support */ + [data-theme="dark"] .vanilla-datepicker { + background-color: var(--c-bkg-card, #1f2937); + color: var(--c-text-base, #f9fafb); + border-color: var(--c-border, #374151); + --datepicker-icon-filter: invert(1); + } + + .datepicker-today-indicator { + position: absolute; + top: 4px; + right: 12px; + width: 6px; + height: 6px; + background-color: var(--c-primary, #007bff); + border-radius: 50%; + opacity: 0.8; + pointer-events: none; + z-index: 1; + } + + .datepicker-animation { + animation: datepicker-highlight 0.3s ease-in-out; + } + + @keyframes datepicker-highlight { + 0% { transform: scale(1); } + 50% { transform: scale(1.02); } + 100% { transform: scale(1); } + } + + .datepicker-error { + border-color: var(--c-danger, #dc3545) !important; + } + + .datepicker-error:focus { + border-color: var(--c-danger, #dc3545) !important; + box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25) !important; + } + + .datepicker-validation-feedback { + display: block; + width: 100%; + margin-top: 0.25rem; + font-size: 0.875rem; + color: var(--c-danger, #dc3545); + } + + /* Responsive design */ + @media (max-width: 768px) { + .vanilla-datepicker { + padding: 10px 12px; + font-size: 16px; /* Prevent zoom on iOS */ + } + + .vanilla-datepicker::-webkit-calendar-picker-indicator { + width: 20px; + height: 20px; + } + } + `; + + document.head.appendChild(style); + } + + private bindEvents(): void { + // Handle click events + this.element.addEventListener('click', this.handleClick.bind(this)); + + // Handle keyboard events + this.element.addEventListener('keydown', this.handleKeydown.bind(this)); + + // Handle change events + this.element.addEventListener('change', this.handleChange.bind(this)); + + // Handle focus events + this.element.addEventListener('focus', this.handleFocus.bind(this)); + + // Handle blur events + this.element.addEventListener('blur', this.handleBlur.bind(this)); + + // Handle input events for real-time validation + this.element.addEventListener('input', this.handleInput.bind(this)); + } + + private handleClick(): void { + this.openPicker(); + } + + private handleKeydown(e: KeyboardEvent): void { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.openPicker(); + } + } + + private handleChange(e: Event): void { + const target = e.target as HTMLInputElement; + this.handleDateChange(target.value); + } + + private handleFocus(): void { + this.element.classList.add('datepicker-animation'); + this.element.setAttribute('aria-expanded', 'true'); + setTimeout(() => { + this.element.classList.remove('datepicker-animation'); + }, 300); + } + + private handleBlur(): void { + this.element.setAttribute('aria-expanded', 'false'); + this.validateInput(); + } + + private handleInput(): void { + this.validateInput(); + } + + private handleIconClick(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + this.openPicker(); + } + + private openPicker(): void { + this.element.focus(); + + // Try to open the native date picker + if (this.element.showPicker && typeof this.element.showPicker === 'function') { + try { + this.element.showPicker(); + } catch (error) { + console.warn('DatePicker: showPicker not supported', error); + } + } + } + + private handleDateChange(selectedDate: string): void { + if (selectedDate) { + // Add visual feedback + this.element.classList.add('datepicker-animation'); + setTimeout(() => { + this.element.classList.remove('datepicker-animation'); + }, 300); + + // Validate the date + const validation = this.validateDate(selectedDate); + + // Create event data + const eventData: DatePickerEvent = { + date: selectedDate, + formattedDate: this.formatDate(selectedDate), + dateObject: new Date(selectedDate), + isValid: validation.isValid, + }; + + // Trigger custom event + const changeEvent = new CustomEvent('datepicker:change', { + detail: eventData, + bubbles: true, + }); + this.element.dispatchEvent(changeEvent); + + // Update validation state + this.updateValidationState(validation); + } + } + + private validateConstraints(): void { + // Set up date constraints based on options + if (this.options.datesDisabled && this.options.datesDisabled.length > 0) { + this.element.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + if (this.options.datesDisabled!.includes(target.value)) { + this.addValidationError('This date is disabled'); + } + }); + } + + if (this.options.daysOfWeekDisabled && this.options.daysOfWeekDisabled.length > 0) { + this.element.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + const date = new Date(target.value); + const dayOfWeek = date.getDay(); + if (this.options.daysOfWeekDisabled!.includes(dayOfWeek)) { + this.addValidationError('This day of the week is disabled'); + } + }); + } + } + + private validateDate(dateString: string): DatePickerValidation { + const errors: string[] = []; + const date = new Date(dateString); + + // Check if date is valid + if (isNaN(date.getTime())) { + errors.push('Invalid date format'); + } + + // Check min/max constraints + if (this.options.minDate) { + const minDate = new Date(this.options.minDate); + if (date < minDate) { + errors.push(`Date must be after ${this.formatDate(this.options.minDate)}`); + } + } + + if (this.options.maxDate) { + const maxDate = new Date(this.options.maxDate); + if (date > maxDate) { + errors.push(`Date must be before ${this.formatDate(this.options.maxDate)}`); + } + } + + // Check disabled dates + if (this.options.datesDisabled && this.options.datesDisabled.includes(dateString)) { + errors.push('This date is disabled'); + } + + // Check disabled days of week + if (this.options.daysOfWeekDisabled && this.options.daysOfWeekDisabled.includes(date.getDay())) { + errors.push('This day of the week is disabled'); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + private validateInput(): void { + const value = this.element.value; + if (value) { + const validation = this.validateDate(value); + this.updateValidationState(validation); + } else { + this.clearValidationState(); + } + } + + private updateValidationState(validation: DatePickerValidation): void { + this.validationErrors = validation.errors; + + // Remove existing validation feedback + this.clearValidationFeedback(); + + if (!validation.isValid) { + // Add error class + this.element.classList.add('datepicker-error'); + + // Add validation feedback + const feedback = document.createElement('div'); + feedback.className = 'datepicker-validation-feedback'; + feedback.textContent = validation.errors.join(', '); + + if (this.wrapper) { + this.wrapper.appendChild(feedback); + } + + // Set ARIA attributes + this.element.setAttribute('aria-invalid', 'true'); + this.element.setAttribute('aria-describedby', 'datepicker-error'); + feedback.id = 'datepicker-error'; + } else { + this.clearValidationState(); + } + } + + private clearValidationState(): void { + this.element.classList.remove('datepicker-error'); + this.element.setAttribute('aria-invalid', 'false'); + this.element.removeAttribute('aria-describedby'); + this.validationErrors = []; + this.clearValidationFeedback(); + } + + private clearValidationFeedback(): void { + if (this.wrapper) { + const existingFeedback = this.wrapper.querySelector('.datepicker-validation-feedback'); + if (existingFeedback) { + existingFeedback.remove(); + } + } + } + + private addValidationError(error: string): void { + if (!this.validationErrors.includes(error)) { + this.validationErrors.push(error); + } + } + + private addTodayHighlight(): void { + if (this.options.todayHighlight) { + const today = DateUtils.formatters.inputDate(DateUtils.now()); + if (this.element.value === today) { + this.todayIndicator = document.createElement('div'); + this.todayIndicator.className = 'datepicker-today-indicator'; + this.todayIndicator.title = 'Today'; + + if (this.wrapper) { + this.wrapper.appendChild(this.todayIndicator); + } + } + } + } + + private formatDate(dateString: string): string { + try { + const date = new Date(dateString); + return DateUtils.format(date, this.options.format || 'yyyy-mm-dd'); + } catch (error) { + return dateString; + } + } + + // Public API methods + public setDate(dateString: string): void { + this.element.value = dateString; + this.handleDateChange(dateString); + } + + public getDate(): string { + return this.element.value; + } + + public getFormattedDate(): string { + return this.formatDate(this.element.value); + } + + public getDateObject(): Date | null { + return this.element.value ? new Date(this.element.value) : null; + } + + public isValid(): boolean { + return this.validationErrors.length === 0; + } + + public getValidationErrors(): string[] { + return [...this.validationErrors]; + } + + public setMinDate(dateString: string): void { + this.options.minDate = dateString; + this.element.min = dateString; + this.validateInput(); + } + + public setMaxDate(dateString: string): void { + this.options.maxDate = dateString; + this.element.max = dateString; + this.validateInput(); + } + + public reset(): void { + this.element.value = ''; + this.clearValidationState(); + if (this.todayIndicator) { + this.todayIndicator.remove(); + this.todayIndicator = null; + } + } + + public enable(): void { + this.element.disabled = false; + } + + public disable(): void { + this.element.disabled = true; + } + + public updateOptions(newOptions: Partial): void { + this.options = { ...this.options, ...newOptions }; + this.validateConstraints(); + this.validateInput(); + } +} + +// DatePicker Manager +export class DatePickerManager { + private instances: Map = new Map(); + + public initialize(selector: string, options: DatePickerOptions = {}): VanillaDatePicker[] { + const elements = document.querySelectorAll(selector); + const instances: VanillaDatePicker[] = []; + + elements.forEach((element, index) => { + // Clean up existing instance + if (element.vanillaDatePicker) { + element.vanillaDatePicker.destroy(); + } + + // Create new instance + const datePicker = new VanillaDatePicker(element, options); + element.vanillaDatePicker = datePicker; + + // Store in manager + const key = `${selector}-${index}`; + this.instances.set(key, datePicker); + instances.push(datePicker); + }); + + return instances; + } + + public getInstances(selector: string): VanillaDatePicker[] { + const instances: VanillaDatePicker[] = []; + this.instances.forEach((instance, key) => { + if (key.startsWith(selector)) { + instances.push(instance); + } + }); + return instances; + } + + public destroyInstances(selector: string): void { + const keysToDelete: string[] = []; + this.instances.forEach((instance, key) => { + if (key.startsWith(selector)) { + instance.destroy(); + keysToDelete.push(key); + } + }); + keysToDelete.forEach(key => this.instances.delete(key)); + } + + public destroyAll(): void { + this.instances.forEach(instance => instance.destroy()); + this.instances.clear(); + } +} + +// Create singleton manager +const datePickerManager = new DatePickerManager(); + +// Initialize date pickers +const initializeDatePickers = (): void => { + // Start date pickers + datePickerManager.initialize('.start-date', { + format: 'yyyy-mm-dd', + autoclose: true, + todayHighlight: true, + }); + + // End date pickers + datePickerManager.initialize('.end-date', { + format: 'yyyy-mm-dd', + autoclose: true, + todayHighlight: true, + }); + + // Generic date pickers + datePickerManager.initialize('input[type="date"]:not(.start-date):not(.end-date)', { + format: 'yyyy-mm-dd', + autoclose: true, + todayHighlight: true, + }); +}; + +// Initialize on load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeDatePickers); +} else { + initializeDatePickers(); +} + +// Reinitialize on theme change +window.addEventListener('adminator:themeChanged', () => { + setTimeout(initializeDatePickers, 100); +}); + +// Cleanup on page unload +window.addEventListener('beforeunload', () => { + datePickerManager.destroyAll(); +}); + +// Export default for compatibility +export default { + init: initializeDatePickers, + manager: datePickerManager, + VanillaDatePicker, + DatePickerManager, +}; \ No newline at end of file diff --git a/src/assets/scripts/ui/index.js b/src/assets/scripts/ui/index.js index 94060b0..84542ca 100644 --- a/src/assets/scripts/ui/index.js +++ b/src/assets/scripts/ui/index.js @@ -277,22 +277,22 @@ export default (function () { const rect = this.element.getBoundingClientRect(); switch (placement) { - case 'top': - this.tooltip.style.left = `${rect.left + (rect.width / 2) - (this.tooltip.offsetWidth / 2)}px`; - this.tooltip.style.top = `${rect.top - this.tooltip.offsetHeight - 5}px`; - break; - case 'bottom': - this.tooltip.style.left = `${rect.left + (rect.width / 2) - (this.tooltip.offsetWidth / 2)}px`; - this.tooltip.style.top = `${rect.bottom + 5}px`; - break; - case 'left': - this.tooltip.style.left = `${rect.left - this.tooltip.offsetWidth - 5}px`; - this.tooltip.style.top = `${rect.top + (rect.height / 2) - (this.tooltip.offsetHeight / 2)}px`; - break; - case 'right': - this.tooltip.style.left = `${rect.right + 5}px`; - this.tooltip.style.top = `${rect.top + (rect.height / 2) - (this.tooltip.offsetHeight / 2)}px`; - break; + case 'top': + this.tooltip.style.left = `${rect.left + (rect.width / 2) - (this.tooltip.offsetWidth / 2)}px`; + this.tooltip.style.top = `${rect.top - this.tooltip.offsetHeight - 5}px`; + break; + case 'bottom': + this.tooltip.style.left = `${rect.left + (rect.width / 2) - (this.tooltip.offsetWidth / 2)}px`; + this.tooltip.style.top = `${rect.bottom + 5}px`; + break; + case 'left': + this.tooltip.style.left = `${rect.left - this.tooltip.offsetWidth - 5}px`; + this.tooltip.style.top = `${rect.top + (rect.height / 2) - (this.tooltip.offsetHeight / 2)}px`; + break; + case 'right': + this.tooltip.style.left = `${rect.right + 5}px`; + this.tooltip.style.top = `${rect.top + (rect.height / 2) - (this.tooltip.offsetHeight / 2)}px`; + break; } } @@ -407,6 +407,6 @@ export default (function () { Dropdown: VanillaDropdown, Popover: VanillaPopover, Tooltip: VanillaTooltip, - Accordion: VanillaAccordion + Accordion: VanillaAccordion, }; }()); \ No newline at end of file diff --git a/src/assets/scripts/ui/index.ts b/src/assets/scripts/ui/index.ts new file mode 100644 index 0000000..cee82d4 --- /dev/null +++ b/src/assets/scripts/ui/index.ts @@ -0,0 +1,740 @@ +/** + * UI Bootstrap Components with TypeScript + * Vanilla JavaScript implementations for Bootstrap components + */ + +import type { ComponentInterface } from '../../types'; + +// Type definitions for UI components +export interface UIComponentOptions { + autoInit?: boolean; + selector?: string; +} + +export interface TooltipOptions { + placement?: 'top' | 'bottom' | 'left' | 'right'; + delay?: number; + animation?: boolean; +} + +export interface PopoverOptions { + placement?: 'top' | 'bottom' | 'left' | 'right'; + trigger?: 'click' | 'hover' | 'focus' | 'manual'; + html?: boolean; + animation?: boolean; +} + +export interface ModalOptions { + backdrop?: boolean | 'static'; + keyboard?: boolean; + focus?: boolean; + show?: boolean; +} + +export interface AccordionOptions { + parent?: string; + toggle?: boolean; +} + +export interface DropdownOptions { + offset?: [number, number]; + flip?: boolean; + boundary?: 'clippingParents' | 'viewport' | HTMLElement; +} + +// Modal functionality +export class VanillaModal implements ComponentInterface { + public name: string = 'VanillaModal'; + public element: HTMLElement; + public options: ModalOptions; + public isInitialized: boolean = false; + + private modal: HTMLElement | null = null; + private backdrop: HTMLElement | null = null; + private isOpen: boolean = false; + private escapeHandler: ((e: KeyboardEvent) => void) | null = null; + + constructor(element: HTMLElement, options: ModalOptions = {}) { + this.element = element; + this.options = { + backdrop: true, + keyboard: true, + focus: true, + show: false, + ...options, + }; + this.init(); + } + + public init(): void { + const targetSelector = this.element.getAttribute('data-bs-target'); + if (!targetSelector) { + console.warn('Modal: Missing data-bs-target attribute'); + return; + } + + this.modal = document.querySelector(targetSelector); + if (!this.modal) { + console.warn(`Modal: Target element ${targetSelector} not found`); + return; + } + + this.element.addEventListener('click', this.handleElementClick.bind(this)); + + // Close button functionality + const closeButtons = this.modal.querySelectorAll('[data-bs-dismiss="modal"]'); + closeButtons.forEach(btn => { + btn.addEventListener('click', this.hide.bind(this)); + }); + + // Close on backdrop click + if (this.options.backdrop !== false) { + this.modal.addEventListener('click', this.handleBackdropClick.bind(this)); + } + + this.isInitialized = true; + } + + public destroy(): void { + if (this.escapeHandler) { + document.removeEventListener('keydown', this.escapeHandler); + this.escapeHandler = null; + } + this.hide(); + this.isInitialized = false; + } + + private handleElementClick(e: Event): void { + e.preventDefault(); + this.show(); + } + + private handleBackdropClick(e: Event): void { + if (e.target === this.modal && this.options.backdrop !== 'static') { + this.hide(); + } + } + + public show(): void { + if (this.isOpen || !this.modal) return; + + // Create backdrop + if (this.options.backdrop !== false) { + this.backdrop = document.createElement('div'); + this.backdrop.className = 'modal-backdrop fade show'; + document.body.appendChild(this.backdrop); + } + + // Show modal + this.modal.style.display = 'block'; + this.modal.classList.add('show'); + document.body.classList.add('modal-open'); + + this.isOpen = true; + + // Focus the modal + if (this.options.focus) { + this.modal.setAttribute('tabindex', '-1'); + this.modal.focus(); + } + + // Escape key handler + if (this.options.keyboard) { + this.escapeHandler = this.handleEscapeKey.bind(this); + document.addEventListener('keydown', this.escapeHandler); + } + } + + public hide(): void { + if (!this.isOpen || !this.modal) return; + + // Hide modal + this.modal.classList.remove('show'); + this.modal.style.display = 'none'; + document.body.classList.remove('modal-open'); + + // Remove backdrop + if (this.backdrop) { + this.backdrop.remove(); + this.backdrop = null; + } + + this.isOpen = false; + + // Remove escape handler + if (this.escapeHandler) { + document.removeEventListener('keydown', this.escapeHandler); + this.escapeHandler = null; + } + } + + private handleEscapeKey(e: KeyboardEvent): void { + if (e.key === 'Escape') { + this.hide(); + } + } + + public toggle(): void { + if (this.isOpen) { + this.hide(); + } else { + this.show(); + } + } + + public isVisible(): boolean { + return this.isOpen; + } +} + +// Dropdown functionality +export class VanillaDropdown implements ComponentInterface { + public name: string = 'VanillaDropdown'; + public element: HTMLElement; + public options: DropdownOptions; + public isInitialized: boolean = false; + + private menu: HTMLElement | null = null; + private isOpen: boolean = false; + private outsideClickHandler: ((e: Event) => void) | null = null; + private escapeHandler: ((e: KeyboardEvent) => void) | null = null; + + constructor(element: HTMLElement, options: DropdownOptions = {}) { + this.element = element; + this.options = { + offset: [0, 2], + flip: true, + boundary: 'clippingParents', + ...options, + }; + this.init(); + } + + public init(): void { + const parent = this.element.parentNode as HTMLElement; + if (!parent) return; + + this.menu = parent.querySelector('.dropdown-menu'); + if (!this.menu) return; + + this.element.addEventListener('click', this.handleElementClick.bind(this)); + + // Setup event handlers + this.outsideClickHandler = this.handleOutsideClick.bind(this); + this.escapeHandler = this.handleEscapeKey.bind(this); + + this.isInitialized = true; + } + + public destroy(): void { + this.hide(); + if (this.outsideClickHandler) { + document.removeEventListener('click', this.outsideClickHandler); + this.outsideClickHandler = null; + } + if (this.escapeHandler) { + document.removeEventListener('keydown', this.escapeHandler); + this.escapeHandler = null; + } + this.isInitialized = false; + } + + private handleElementClick(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + this.toggle(); + } + + private handleOutsideClick(e: Event): void { + const parent = this.element.parentNode as HTMLElement; + if (parent && !parent.contains(e.target as Node)) { + this.hide(); + } + } + + private handleEscapeKey(e: KeyboardEvent): void { + if (e.key === 'Escape' && this.isOpen) { + this.hide(); + } + } + + public toggle(): void { + if (this.isOpen) { + this.hide(); + } else { + this.show(); + } + } + + public show(): void { + if (this.isOpen || !this.menu) return; + + // Close other dropdowns + document.querySelectorAll('.dropdown-menu.show').forEach(menu => { + menu.classList.remove('show'); + }); + + this.menu.classList.add('show'); + this.element.setAttribute('aria-expanded', 'true'); + this.isOpen = true; + + // Add event listeners + if (this.outsideClickHandler) { + document.addEventListener('click', this.outsideClickHandler); + } + if (this.escapeHandler) { + document.addEventListener('keydown', this.escapeHandler); + } + } + + public hide(): void { + if (!this.isOpen || !this.menu) return; + + this.menu.classList.remove('show'); + this.element.setAttribute('aria-expanded', 'false'); + this.isOpen = false; + + // Remove event listeners + if (this.outsideClickHandler) { + document.removeEventListener('click', this.outsideClickHandler); + } + if (this.escapeHandler) { + document.removeEventListener('keydown', this.escapeHandler); + } + } +} + +// Popover functionality +export class VanillaPopover implements ComponentInterface { + public name: string = 'VanillaPopover'; + public element: HTMLElement; + public options: PopoverOptions; + public isInitialized: boolean = false; + + private popover: HTMLElement | null = null; + private isOpen: boolean = false; + private outsideClickHandler: ((e: Event) => void) | null = null; + + constructor(element: HTMLElement, options: PopoverOptions = {}) { + this.element = element; + this.options = { + placement: 'top', + trigger: 'click', + html: false, + animation: true, + ...options, + }; + this.init(); + } + + public init(): void { + if (this.options.trigger === 'click') { + this.element.addEventListener('click', this.handleElementClick.bind(this)); + } else if (this.options.trigger === 'hover') { + this.element.addEventListener('mouseenter', this.show.bind(this)); + this.element.addEventListener('mouseleave', this.hide.bind(this)); + } else if (this.options.trigger === 'focus') { + this.element.addEventListener('focus', this.show.bind(this)); + this.element.addEventListener('blur', this.hide.bind(this)); + } + + this.outsideClickHandler = this.handleOutsideClick.bind(this); + this.isInitialized = true; + } + + public destroy(): void { + this.hide(); + if (this.outsideClickHandler) { + document.removeEventListener('click', this.outsideClickHandler); + this.outsideClickHandler = null; + } + this.isInitialized = false; + } + + private handleElementClick(e: Event): void { + e.preventDefault(); + this.toggle(); + } + + private handleOutsideClick(e: Event): void { + if (!this.element.contains(e.target as Node) && + (!this.popover || !this.popover.contains(e.target as Node))) { + this.hide(); + } + } + + public toggle(): void { + if (this.isOpen) { + this.hide(); + } else { + this.show(); + } + } + + public show(): void { + if (this.isOpen) return; + + // Close other popovers + document.querySelectorAll('.popover').forEach(popover => { + popover.remove(); + }); + + const title = this.element.getAttribute('title') || this.element.getAttribute('data-bs-title'); + const content = this.element.getAttribute('data-bs-content'); + + if (!content) return; + + this.popover = document.createElement('div'); + this.popover.className = `popover bs-popover-${this.options.placement} show`; + this.popover.style.position = 'absolute'; + this.popover.style.zIndex = '1070'; + this.popover.style.maxWidth = '276px'; + this.popover.style.backgroundColor = '#fff'; + this.popover.style.border = '1px solid rgba(0,0,0,.2)'; + this.popover.style.borderRadius = '6px'; + this.popover.style.boxShadow = '0 5px 10px rgba(0,0,0,.2)'; + + let popoverContent = ''; + if (title) { + popoverContent += `
${title}
`; + } + popoverContent += `
${content}
`; + + this.popover.innerHTML = popoverContent; + document.body.appendChild(this.popover); + + // Position popover + this.positionPopover(); + + this.isOpen = true; + + // Add outside click handler + if (this.outsideClickHandler) { + document.addEventListener('click', this.outsideClickHandler); + } + } + + public hide(): void { + if (!this.isOpen) return; + + if (this.popover) { + this.popover.remove(); + this.popover = null; + } + this.isOpen = false; + + // Remove outside click handler + if (this.outsideClickHandler) { + document.removeEventListener('click', this.outsideClickHandler); + } + } + + private positionPopover(): void { + if (!this.popover) return; + + const rect = this.element.getBoundingClientRect(); + const popoverRect = this.popover.getBoundingClientRect(); + + switch (this.options.placement) { + case 'top': + this.popover.style.left = `${rect.left + (rect.width / 2) - (popoverRect.width / 2)}px`; + this.popover.style.top = `${rect.top - popoverRect.height - 10}px`; + break; + case 'bottom': + this.popover.style.left = `${rect.left + (rect.width / 2) - (popoverRect.width / 2)}px`; + this.popover.style.top = `${rect.bottom + 10}px`; + break; + case 'left': + this.popover.style.left = `${rect.left - popoverRect.width - 10}px`; + this.popover.style.top = `${rect.top + (rect.height / 2) - (popoverRect.height / 2)}px`; + break; + case 'right': + this.popover.style.left = `${rect.right + 10}px`; + this.popover.style.top = `${rect.top + (rect.height / 2) - (popoverRect.height / 2)}px`; + break; + } + } +} + +// Tooltip functionality +export class VanillaTooltip implements ComponentInterface { + public name: string = 'VanillaTooltip'; + public element: HTMLElement; + public options: TooltipOptions; + public isInitialized: boolean = false; + + private tooltip: HTMLElement | null = null; + private showTimeout: number | null = null; + private hideTimeout: number | null = null; + + constructor(element: HTMLElement, options: TooltipOptions = {}) { + this.element = element; + this.options = { + placement: 'top', + delay: 0, + animation: true, + ...options, + }; + this.init(); + } + + public init(): void { + this.element.addEventListener('mouseenter', this.handleMouseEnter.bind(this)); + this.element.addEventListener('mouseleave', this.handleMouseLeave.bind(this)); + this.element.addEventListener('focus', this.handleFocus.bind(this)); + this.element.addEventListener('blur', this.handleBlur.bind(this)); + this.isInitialized = true; + } + + public destroy(): void { + this.hide(); + this.clearTimeouts(); + this.isInitialized = false; + } + + private handleMouseEnter(): void { + this.clearTimeouts(); + this.showTimeout = window.setTimeout(() => this.show(), this.options.delay); + } + + private handleMouseLeave(): void { + this.clearTimeouts(); + this.hideTimeout = window.setTimeout(() => this.hide(), this.options.delay); + } + + private handleFocus(): void { + this.show(); + } + + private handleBlur(): void { + this.hide(); + } + + private clearTimeouts(): void { + if (this.showTimeout) { + clearTimeout(this.showTimeout); + this.showTimeout = null; + } + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + } + + public show(): void { + if (this.tooltip) return; + + const title = this.element.getAttribute('title') || this.element.getAttribute('data-bs-title'); + if (!title) return; + + this.tooltip = document.createElement('div'); + this.tooltip.className = `tooltip bs-tooltip-${this.options.placement} show`; + this.tooltip.style.position = 'absolute'; + this.tooltip.style.zIndex = '1070'; + this.tooltip.style.maxWidth = '200px'; + this.tooltip.style.padding = '4px 8px'; + this.tooltip.style.fontSize = '12px'; + this.tooltip.style.backgroundColor = '#000'; + this.tooltip.style.color = '#fff'; + this.tooltip.style.borderRadius = '4px'; + this.tooltip.style.pointerEvents = 'none'; + this.tooltip.style.whiteSpace = 'nowrap'; + + this.tooltip.innerHTML = `
${title}
`; + document.body.appendChild(this.tooltip); + + // Position tooltip + this.positionTooltip(); + } + + public hide(): void { + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } + } + + private positionTooltip(): void { + if (!this.tooltip) return; + + const rect = this.element.getBoundingClientRect(); + const tooltipRect = this.tooltip.getBoundingClientRect(); + + switch (this.options.placement) { + case 'top': + this.tooltip.style.left = `${rect.left + (rect.width / 2) - (tooltipRect.width / 2)}px`; + this.tooltip.style.top = `${rect.top - tooltipRect.height - 5}px`; + break; + case 'bottom': + this.tooltip.style.left = `${rect.left + (rect.width / 2) - (tooltipRect.width / 2)}px`; + this.tooltip.style.top = `${rect.bottom + 5}px`; + break; + case 'left': + this.tooltip.style.left = `${rect.left - tooltipRect.width - 5}px`; + this.tooltip.style.top = `${rect.top + (rect.height / 2) - (tooltipRect.height / 2)}px`; + break; + case 'right': + this.tooltip.style.left = `${rect.right + 5}px`; + this.tooltip.style.top = `${rect.top + (rect.height / 2) - (tooltipRect.height / 2)}px`; + break; + } + } +} + +// Accordion functionality +export class VanillaAccordion implements ComponentInterface { + public name: string = 'VanillaAccordion'; + public element: HTMLElement; + public options: AccordionOptions; + public isInitialized: boolean = false; + + private accordion: HTMLElement | null = null; + private target: HTMLElement | null = null; + private isOpen: boolean = false; + + constructor(element: HTMLElement, options: AccordionOptions = {}) { + this.element = element; + this.options = { + toggle: true, + ...options, + }; + this.init(); + } + + public init(): void { + this.accordion = this.element.closest('.accordion'); + const targetSelector = this.element.getAttribute('data-bs-target'); + if (!targetSelector) return; + + this.target = document.querySelector(targetSelector); + if (!this.target) return; + + this.isOpen = !this.element.classList.contains('collapsed'); + this.element.addEventListener('click', this.handleElementClick.bind(this)); + this.isInitialized = true; + } + + public destroy(): void { + this.isInitialized = false; + } + + private handleElementClick(e: Event): void { + e.preventDefault(); + this.toggle(); + } + + public toggle(): void { + if (this.isOpen) { + this.hide(); + } else { + this.show(); + } + } + + public show(): void { + if (this.isOpen || !this.target) return; + + // Close other accordion items in the same parent + if (this.accordion) { + const otherItems = this.accordion.querySelectorAll('.accordion-collapse.show'); + otherItems.forEach(item => { + if (item !== this.target) { + item.classList.remove('show'); + const button = this.accordion!.querySelector(`[data-bs-target="#${item.id}"]`); + if (button) { + button.classList.add('collapsed'); + button.setAttribute('aria-expanded', 'false'); + } + } + }); + } + + // Show this item + this.target.classList.add('show'); + this.element.classList.remove('collapsed'); + this.element.setAttribute('aria-expanded', 'true'); + this.isOpen = true; + } + + public hide(): void { + if (!this.isOpen || !this.target) return; + + this.target.classList.remove('show'); + this.element.classList.add('collapsed'); + this.element.setAttribute('aria-expanded', 'false'); + this.isOpen = false; + } +} + +// UI Manager Class +export class UIManager { + private components: Map = new Map(); + + public initializeComponents(): void { + // Initialize modals + document.querySelectorAll('[data-bs-toggle="modal"]').forEach(element => { + const modal = new VanillaModal(element); + this.components.set(`modal-${element.id || Date.now()}`, modal); + }); + + // Initialize dropdowns + document.querySelectorAll('[data-bs-toggle="dropdown"]').forEach(element => { + const dropdown = new VanillaDropdown(element); + this.components.set(`dropdown-${element.id || Date.now()}`, dropdown); + }); + + // Initialize popovers + document.querySelectorAll('[data-bs-toggle="popover"]').forEach(element => { + const popover = new VanillaPopover(element); + this.components.set(`popover-${element.id || Date.now()}`, popover); + }); + + // Initialize tooltips + document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(element => { + const tooltip = new VanillaTooltip(element); + this.components.set(`tooltip-${element.id || Date.now()}`, tooltip); + }); + + // Initialize accordions + document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(element => { + const accordion = new VanillaAccordion(element); + this.components.set(`accordion-${element.id || Date.now()}`, accordion); + }); + } + + public destroyComponents(): void { + this.components.forEach(component => { + component.destroy(); + }); + this.components.clear(); + } + + public getComponent(id: string): ComponentInterface | undefined { + return this.components.get(id); + } +} + +// Create and export singleton instance +const uiManager = new UIManager(); + +// Initialize when DOM is ready +const initializeUI = (): void => { + uiManager.initializeComponents(); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeUI); +} else { + initializeUI(); +} + +// Export default object for compatibility +export default { + init: initializeUI, + manager: uiManager, + Modal: VanillaModal, + Dropdown: VanillaDropdown, + Popover: VanillaPopover, + Tooltip: VanillaTooltip, + Accordion: VanillaAccordion, +}; \ No newline at end of file diff --git a/src/assets/scripts/utils/date.ts b/src/assets/scripts/utils/date.ts new file mode 100644 index 0000000..e6eafd7 --- /dev/null +++ b/src/assets/scripts/utils/date.ts @@ -0,0 +1,363 @@ +/** + * Modern Date Utilities with TypeScript + * Using Day.js (2KB) instead of Moment.js (67KB) - 97% size reduction + * Provides consistent date formatting and manipulation across the application + */ + +import dayjs, { Dayjs, ConfigType, UnitType, ManipulateType } from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import advancedFormat from 'dayjs/plugin/advancedFormat'; +import isBetween from 'dayjs/plugin/isBetween'; + +// Enable Day.js plugins +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(relativeTime); +dayjs.extend(customParseFormat); +dayjs.extend(advancedFormat); +dayjs.extend(isBetween); + +// Type definitions +export interface CalendarDay { + date: string; + day: number; + isCurrentMonth: boolean; + isToday: boolean; + dayjs: Dayjs; +} + +export interface CalendarMonth { + month: string; + year: number; + monthIndex: number; + days: CalendarDay[]; +} + +export interface WeekDay { + date: string; + day: number; + dayName: string; + shortDayName: string; + isToday: boolean; + dayjs: Dayjs; +} + +export interface WeekData { + weekStart: string; + weekEnd: string; + days: WeekDay[]; +} + +export interface ChartDatePoint { + date: string; + label: string; + value: string; + dayjs: Dayjs; +} + +export type DateInput = ConfigType; +export type DateUnit = UnitType; +export type DateManipulateUnit = ManipulateType; + +export interface DateFormatters { + shortDate: (date: DateInput) => string; + longDate: (date: DateInput) => string; + dateTime: (date: DateInput) => string; + calendarDate: (date: DateInput) => string; + calendarDateTime: (date: DateInput) => string; + inputDate: (date: DateInput) => string; + inputDateTime: (date: DateInput) => string; + timeOnly: (date: DateInput) => string; + monthYear: (date: DateInput) => string; + dayMonth: (date: DateInput) => string; + relative: (date: DateInput) => string; + relativeCalendar: (date: DateInput) => string; +} + +export interface DateCalendarUtils { + getMonthData: (date?: DateInput) => CalendarMonth; + getWeekData: (date?: DateInput) => WeekData; +} + +export interface DateFormUtils { + toInputValue: (date: DateInput) => string; + toDateTimeInputValue: (date: DateInput) => string; + fromInputValue: (value: string) => Dayjs; + validateDateInput: (value: string) => boolean; +} + +export interface DateChartUtils { + generateDateRange: (start: DateInput, end: DateInput, interval?: DateManipulateUnit) => ChartDatePoint[]; + getChartLabels: (period?: 'week' | 'month' | 'year') => string[]; +} + +export interface DateTimezoneUtils { + convert: (date: DateInput, tz: string) => Dayjs; + utc: (date: DateInput) => Dayjs; + local: (date: DateInput) => Dayjs; + guess: () => string; +} + +export interface DateUtilsInterface { + now: () => Dayjs; + parse: (input: DateInput, format?: string) => Dayjs; + format: (date: DateInput, format?: string) => string; + formatters: DateFormatters; + add: (date: DateInput, amount: number, unit: DateManipulateUnit) => Dayjs; + subtract: (date: DateInput, amount: number, unit: DateManipulateUnit) => Dayjs; + startOf: (date: DateInput, unit: DateUnit) => Dayjs; + endOf: (date: DateInput, unit: DateUnit) => Dayjs; + isBefore: (date1: DateInput, date2: DateInput) => boolean; + isAfter: (date1: DateInput, date2: DateInput) => boolean; + isSame: (date1: DateInput, date2: DateInput, unit?: DateUnit) => boolean; + isBetween: (date: DateInput, start: DateInput, end: DateInput) => boolean; + isValid: (date: DateInput) => boolean; + timezone: DateTimezoneUtils; + calendar: DateCalendarUtils; + form: DateFormUtils; + charts: DateChartUtils; +} + +export const DateUtils: DateUtilsInterface = { + /** + * Get current date/time + */ + now: (): Dayjs => dayjs(), + + /** + * Parse date from string or Date object + */ + parse: (input: DateInput, format?: string): Dayjs => { + return format ? dayjs(input, format) : dayjs(input); + }, + + /** + * Format date for display + */ + format: (date: DateInput, format: string = 'YYYY-MM-DD'): string => { + return dayjs(date).format(format); + }, + + /** + * Common date formatting presets + */ + formatters: { + // Dashboard display formats + shortDate: (date: DateInput): string => dayjs(date).format('MMM DD, YYYY'), + longDate: (date: DateInput): string => dayjs(date).format('MMMM DD, YYYY'), + dateTime: (date: DateInput): string => dayjs(date).format('MMM DD, YYYY h:mm A'), + + // Calendar formats + calendarDate: (date: DateInput): string => dayjs(date).format('YYYY-MM-DD'), + calendarDateTime: (date: DateInput): string => dayjs(date).format('YYYY-MM-DD HH:mm:ss'), + + // Form input formats + inputDate: (date: DateInput): string => dayjs(date).format('YYYY-MM-DD'), + inputDateTime: (date: DateInput): string => dayjs(date).format('YYYY-MM-DDTHH:mm'), + + // Display formats + timeOnly: (date: DateInput): string => dayjs(date).format('h:mm A'), + monthYear: (date: DateInput): string => dayjs(date).format('MMMM YYYY'), + dayMonth: (date: DateInput): string => dayjs(date).format('DD MMM'), + + // Relative time + relative: (date: DateInput): string => dayjs(date).fromNow(), + relativeCalendar: (date: DateInput): string => { + const now = dayjs(); + const target = dayjs(date); + const diffDays = now.diff(target, 'day'); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays === -1) return 'Tomorrow'; + if (diffDays > 1 && diffDays < 7) return `${diffDays} days ago`; + if (diffDays < -1 && diffDays > -7) return `In ${Math.abs(diffDays)} days`; + return target.format('MMM DD, YYYY'); + }, + }, + + /** + * Date manipulation + */ + add: (date: DateInput, amount: number, unit: DateManipulateUnit): Dayjs => + dayjs(date).add(amount, unit), + + subtract: (date: DateInput, amount: number, unit: DateManipulateUnit): Dayjs => + dayjs(date).subtract(amount, unit), + + startOf: (date: DateInput, unit: DateUnit): Dayjs => + dayjs(date).startOf(unit), + + endOf: (date: DateInput, unit: DateUnit): Dayjs => + dayjs(date).endOf(unit), + + /** + * Date comparison + */ + isBefore: (date1: DateInput, date2: DateInput): boolean => + dayjs(date1).isBefore(dayjs(date2)), + + isAfter: (date1: DateInput, date2: DateInput): boolean => + dayjs(date1).isAfter(dayjs(date2)), + + isSame: (date1: DateInput, date2: DateInput, unit: DateUnit = 'day'): boolean => + dayjs(date1).isSame(dayjs(date2), unit), + + isBetween: (date: DateInput, start: DateInput, end: DateInput): boolean => + dayjs(date).isBetween(dayjs(start), dayjs(end)), + + /** + * Date validation + */ + isValid: (date: DateInput): boolean => dayjs(date).isValid(), + + /** + * Timezone utilities + */ + timezone: { + convert: (date: DateInput, tz: string): Dayjs => dayjs(date).tz(tz), + utc: (date: DateInput): Dayjs => dayjs(date).utc(), + local: (date: DateInput): Dayjs => dayjs(date).local(), + guess: (): string => dayjs.tz.guess(), + }, + + /** + * Calendar utilities + */ + calendar: { + // Get calendar month data for building calendar views + getMonthData: (date?: DateInput): CalendarMonth => { + const target = date ? dayjs(date) : dayjs(); + const startOfMonth = target.startOf('month'); + const endOfMonth = target.endOf('month'); + const startOfCalendar = startOfMonth.startOf('week'); + const endOfCalendar = endOfMonth.endOf('week'); + + const days: CalendarDay[] = []; + let current = startOfCalendar; + + while (current.isBefore(endOfCalendar) || current.isSame(endOfCalendar, 'day')) { + days.push({ + date: current.format('YYYY-MM-DD'), + day: current.date(), + isCurrentMonth: current.isSame(target, 'month'), + isToday: current.isSame(dayjs(), 'day'), + dayjs: current.clone(), + }); + current = current.add(1, 'day'); + } + + return { + month: target.format('MMMM YYYY'), + year: target.year(), + monthIndex: target.month(), + days, + }; + }, + + // Get week data + getWeekData: (date?: DateInput): WeekData => { + const target = date ? dayjs(date) : dayjs(); + const startOfWeek = target.startOf('week'); + const endOfWeek = target.endOf('week'); + + const days: WeekDay[] = []; + let current = startOfWeek; + + while (current.isBefore(endOfWeek) || current.isSame(endOfWeek, 'day')) { + days.push({ + date: current.format('YYYY-MM-DD'), + day: current.date(), + dayName: current.format('dddd'), + shortDayName: current.format('ddd'), + isToday: current.isSame(dayjs(), 'day'), + dayjs: current.clone(), + }); + current = current.add(1, 'day'); + } + + return { + weekStart: startOfWeek.format('MMM DD'), + weekEnd: endOfWeek.format('MMM DD, YYYY'), + days, + }; + }, + }, + + /** + * Form utilities + */ + form: { + // Convert date to HTML5 input format + toInputValue: (date: DateInput): string => dayjs(date).format('YYYY-MM-DD'), + toDateTimeInputValue: (date: DateInput): string => dayjs(date).format('YYYY-MM-DDTHH:mm'), + + // Parse from HTML5 input + fromInputValue: (value: string): Dayjs => dayjs(value), + + // Validate date input + validateDateInput: (value: string): boolean => { + const parsed = dayjs(value); + return parsed.isValid() && value.length >= 8; // Basic validation + }, + }, + + /** + * Chart/Data utilities + */ + charts: { + // Generate date ranges for charts + generateDateRange: ( + start: DateInput, + end: DateInput, + interval: DateManipulateUnit = 'day' + ): ChartDatePoint[] => { + const dates: ChartDatePoint[] = []; + let current = dayjs(start); + const endDate = dayjs(end); + + while (current.isBefore(endDate) || current.isSame(endDate, interval)) { + dates.push({ + date: current.format('YYYY-MM-DD'), + label: current.format('MMM DD'), + value: current.toISOString(), + dayjs: current.clone(), + }); + current = current.add(1, interval); + } + + return dates; + }, + + // Get common chart date labels + getChartLabels: (period: 'week' | 'month' | 'year' = 'week'): string[] => { + const now = dayjs(); + + switch (period) { + case 'week': + return Array.from({ length: 7 }, (_, i) => + now.subtract(6 - i, 'day').format('ddd') + ); + case 'month': + return Array.from({ length: 30 }, (_, i) => + now.subtract(29 - i, 'day').format('DD') + ); + case 'year': + return Array.from({ length: 12 }, (_, i) => + now.subtract(11 - i, 'month').format('MMM') + ); + default: + return []; + } + }, + }, +}; + +// Export dayjs instance for direct use when needed +export { dayjs }; + +// Default export +export default DateUtils; \ No newline at end of file diff --git a/src/assets/scripts/utils/dom.ts b/src/assets/scripts/utils/dom.ts new file mode 100644 index 0000000..9661ed4 --- /dev/null +++ b/src/assets/scripts/utils/dom.ts @@ -0,0 +1,513 @@ +/** + * DOM Utility Functions + * Provides jQuery-like functionality using vanilla JavaScript with TypeScript support + */ + +import type { DOMUtilities, AnimationOptions } from '../../../types'; + +export type ElementSelector = string | Element | null; + +interface ElementDimensions { + width: number; + height: number; + top: number; + left: number; + bottom: number; + right: number; +} + +interface SlideAnimationKeyframes { + height: string; +} + +interface FadeAnimationKeyframes { + opacity: number; +} + +/** + * Convert string selector to element or return element as-is + */ +function getElement(element: ElementSelector): Element | null { + if (typeof element === 'string') { + return document.querySelector(element); + } + return element; +} + +/** + * DOM Utility object with type-safe methods + */ +export const DOM: DOMUtilities = { + /** + * Document ready (replaces $(document).ready()) + */ + ready: (callback: () => void): void => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', callback); + } else { + callback(); + } + }, + + /** + * Select single element (replaces $('selector')) + */ + select: (selector: string, context: Element | Document = document): HTMLElement | null => { + return context.querySelector(selector); + }, + + /** + * Select multiple elements (replaces $('selector')) + */ + selectAll: (selector: string, context: Element | Document = document): HTMLElement[] => { + return Array.from(context.querySelectorAll(selector)); + }, + + /** + * Check if element exists + */ + exists: (selector: string, context: Element | Document = document): boolean => { + return context.querySelector(selector) !== null; + }, + + /** + * Add event listener (replaces $.on()) + */ + on: ( + element: Element | Window | Document, + event: string, + handler: (event: Event) => void, + options: AddEventListenerOptions = {} + ): void => { + if (element) { + element.addEventListener(event, handler, options); + } + }, + + /** + * Remove event listener (replaces $.off()) + */ + off: ( + element: Element | Window | Document, + event: string, + handler: (event: Event) => void + ): void => { + if (element) { + element.removeEventListener(event, handler); + } + }, + + /** + * Add class (replaces $.addClass()) + */ + addClass: (element: Element, className: string): void => { + const el = getElement(element); + if (el) { + el.classList.add(className); + } + }, + + /** + * Remove class (replaces $.removeClass()) + */ + removeClass: (element: Element, className: string): void => { + const el = getElement(element); + if (el) { + el.classList.remove(className); + } + }, + + /** + * Toggle class (replaces $.toggleClass()) + */ + toggleClass: (element: Element, className: string): void => { + const el = getElement(element); + if (el) { + el.classList.toggle(className); + } + }, + + /** + * Check if element has class (replaces $.hasClass()) + */ + hasClass: (element: Element, className: string): boolean => { + const el = getElement(element); + return el ? el.classList.contains(className) : false; + }, + + /** + * Get/Set attribute (replaces $.attr()) + */ + attr: (element: Element, name: string, value?: string): string | void => { + const el = getElement(element); + if (!el) return; + + if (value === undefined) { + return el.getAttribute(name) || ''; + } else { + el.setAttribute(name, value); + } + }, + + /** + * Get/Set data attribute (replaces $.data()) + */ + data: (element: Element, name: string, value?: any): any => { + const el = getElement(element); + if (!el) return null; + + const dataName = `data-${name}`; + + if (value === undefined) { + const attrValue = el.getAttribute(dataName); + // Try to parse JSON for complex data + if (attrValue) { + try { + return JSON.parse(attrValue); + } catch { + return attrValue; + } + } + return null; + } else { + const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value); + el.setAttribute(dataName, stringValue); + } + }, +}; + +/** + * Extended DOM utilities with additional functionality + */ +export const DOMExtended = { + ...DOM, + + /** + * Get/Set text content (replaces $.text()) + */ + text: (element: ElementSelector, content?: string): string | void => { + const el = getElement(element); + if (!el) return; + + if (content === undefined) { + return el.textContent || ''; + } else { + el.textContent = content; + } + }, + + /** + * Get/Set HTML content (replaces $.html()) + */ + html: (element: ElementSelector, content?: string): string | void => { + const el = getElement(element); + if (!el) return; + + if (content === undefined) { + return (el as HTMLElement).innerHTML; + } else { + (el as HTMLElement).innerHTML = content; + } + }, + + /** + * Hide element (replaces $.hide()) + */ + hide: (element: ElementSelector): void => { + const el = getElement(element) as HTMLElement; + if (el) { + el.style.display = 'none'; + } + }, + + /** + * Show element (replaces $.show()) + */ + show: (element: ElementSelector, display: string = 'block'): void => { + const el = getElement(element) as HTMLElement; + if (el) { + el.style.display = display; + } + }, + + /** + * Toggle visibility (replaces $.toggle()) + */ + toggle: (element: ElementSelector, display: string = 'block'): void => { + const el = getElement(element) as HTMLElement; + if (el) { + if (el.style.display === 'none') { + el.style.display = display; + } else { + el.style.display = 'none'; + } + } + }, + + /** + * Slide up animation (replaces $.slideUp()) + */ + slideUp: (element: ElementSelector, duration: number = 300): Promise => { + const el = getElement(element) as HTMLElement; + if (!el) return Promise.resolve(); + + return new Promise((resolve) => { + const height = el.scrollHeight; + el.style.height = `${height}px`; + el.style.overflow = 'hidden'; + + const animation = el.animate([ + { height: `${height}px` } as SlideAnimationKeyframes, + { height: '0px' } as SlideAnimationKeyframes, + ], { + duration, + easing: 'ease-in-out', + }); + + animation.onfinish = (): void => { + el.style.display = 'none'; + el.style.height = ''; + el.style.overflow = ''; + resolve(); + }; + }); + }, + + /** + * Slide down animation (replaces $.slideDown()) + */ + slideDown: (element: ElementSelector, duration: number = 300): Promise => { + const el = getElement(element) as HTMLElement; + if (!el) return Promise.resolve(); + + return new Promise((resolve) => { + el.style.display = 'block'; + el.style.height = '0px'; + el.style.overflow = 'hidden'; + + const height = el.scrollHeight; + + const animation = el.animate([ + { height: '0px' } as SlideAnimationKeyframes, + { height: `${height}px` } as SlideAnimationKeyframes, + ], { + duration, + easing: 'ease-in-out', + }); + + animation.onfinish = (): void => { + el.style.height = 'auto'; + el.style.overflow = 'visible'; + resolve(); + }; + }); + }, + + /** + * Fade in animation (replaces $.fadeIn()) + */ + fadeIn: (element: ElementSelector, duration: number = 300): Promise => { + const el = getElement(element) as HTMLElement; + if (!el) return Promise.resolve(); + + return new Promise((resolve) => { + el.style.opacity = '0'; + el.style.display = 'block'; + + const animation = el.animate([ + { opacity: 0 } as FadeAnimationKeyframes, + { opacity: 1 } as FadeAnimationKeyframes, + ], { + duration, + easing: 'ease-in-out', + }); + + animation.onfinish = (): void => { + el.style.opacity = ''; + resolve(); + }; + }); + }, + + /** + * Fade out animation (replaces $.fadeOut()) + */ + fadeOut: (element: ElementSelector, duration: number = 300): Promise => { + const el = getElement(element) as HTMLElement; + if (!el) return Promise.resolve(); + + return new Promise((resolve) => { + const animation = el.animate([ + { opacity: 1 } as FadeAnimationKeyframes, + { opacity: 0 } as FadeAnimationKeyframes, + ], { + duration, + easing: 'ease-in-out', + }); + + animation.onfinish = (): void => { + el.style.display = 'none'; + el.style.opacity = ''; + resolve(); + }; + }); + }, + + /** + * Get element dimensions and position + */ + dimensions: (element: ElementSelector): ElementDimensions | null => { + const el = getElement(element); + if (!el) return null; + + const rect = el.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left, + bottom: rect.bottom, + right: rect.right, + }; + }, + + /** + * Wait for DOM to be ready (replaces $(document).ready()) + */ + ready: (callback: () => void): void => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', callback); + } else { + callback(); + } + }, + + /** + * Create element with attributes and content + */ + create: (tagName: string, attributes?: Record, content?: string): HTMLElement => { + const element = document.createElement(tagName); + + if (attributes) { + Object.entries(attributes).forEach(([key, value]) => { + element.setAttribute(key, value); + }); + } + + if (content) { + element.textContent = content; + } + + return element; + }, + + /** + * Append element to parent + */ + append: (parent: ElementSelector, child: Element): void => { + const parentEl = getElement(parent); + if (parentEl) { + parentEl.appendChild(child); + } + }, + + /** + * Remove element from DOM + */ + remove: (element: ElementSelector): void => { + const el = getElement(element); + if (el && el.parentNode) { + el.parentNode.removeChild(el); + } + }, + + /** + * Get/Set CSS styles + */ + css: (element: ElementSelector, property: string, value?: string): string | void => { + const el = getElement(element) as HTMLElement; + if (!el) return; + + if (value === undefined) { + return window.getComputedStyle(el).getPropertyValue(property); + } else { + el.style.setProperty(property, value); + } + }, + + /** + * Get/Set element value (for form elements) + */ + val: (element: ElementSelector, value?: string): string | void => { + const el = getElement(element) as HTMLInputElement; + if (!el) return; + + if (value === undefined) { + return el.value; + } else { + el.value = value; + } + }, + + /** + * Trigger custom event + */ + trigger: (element: ElementSelector, eventName: string, detail?: any): void => { + const el = getElement(element); + if (el) { + const event = new CustomEvent(eventName, { detail }); + el.dispatchEvent(event); + } + }, + + /** + * Check if element is visible + */ + isVisible: (element: ElementSelector): boolean => { + const el = getElement(element) as HTMLElement; + if (!el) return false; + + const style = window.getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }, + + /** + * Get element offset relative to document + */ + offset: (element: ElementSelector): { top: number; left: number } | null => { + const el = getElement(element); + if (!el) return null; + + const rect = el.getBoundingClientRect(); + return { + top: rect.top + window.pageYOffset, + left: rect.left + window.pageXOffset, + }; + }, + + /** + * Delegate event handling + */ + delegate: ( + parent: ElementSelector, + selector: string, + event: string, + handler: (event: Event) => void + ): void => { + const parentEl = getElement(parent); + if (parentEl) { + parentEl.addEventListener(event, (e) => { + const target = e.target as Element; + if (target && target.matches(selector)) { + handler(e); + } + }); + } + }, +}; + +// Export both the basic DOM utilities and extended version +export { DOM as default, DOMExtended }; + +// Re-export types for convenience +export type { DOMUtilities, ElementSelector, ElementDimensions }; \ No newline at end of file diff --git a/src/assets/scripts/utils/theme.js b/src/assets/scripts/utils/theme.js index 3bc0395..7be0811 100644 --- a/src/assets/scripts/utils/theme.js +++ b/src/assets/scripts/utils/theme.js @@ -1,3 +1,4 @@ +/* global Chart */ const THEME_KEY = 'adminator-theme'; const Theme = { diff --git a/src/assets/scripts/utils/theme.ts b/src/assets/scripts/utils/theme.ts new file mode 100644 index 0000000..ad09aa2 --- /dev/null +++ b/src/assets/scripts/utils/theme.ts @@ -0,0 +1,313 @@ +/** + * Theme Management Utilities + * Handles light/dark mode switching with Chart.js integration + */ + +import type { Theme, ThemeConfig, ThemeColors, ThemeChangeEvent } from '../../../types'; + +declare global { + interface Window { + Chart?: any; // Chart.js global object + } +} + +interface VectorMapColors { + backgroundColor: string; + borderColor: string; + regionColor: string; + markerFill: string; + markerStroke: string; + hoverColor: string; + selectedColor: string; + scaleStart: string; + scaleEnd: string; + scaleLight: string; + scaleDark: string; +} + +interface SparklineColors { + success: string; + purple: string; + info: string; + danger: string; + light: string; +} + +interface ChartThemeColors { + textColor: string; + mutedColor: string; + borderColor: string; + gridColor: string; + tooltipBg: string; +} + +const THEME_KEY = 'adminator-theme'; + +/** + * Theme Management Class + */ +class ThemeManager { + private currentTheme: Theme = 'light'; + private config: ThemeConfig; + + constructor(config?: Partial) { + this.config = { + theme: 'light', + autoDetect: true, + persistChoice: true, + ...config, + }; + } + + /** + * Apply theme to the application + */ + apply(theme: Theme): void { + const previousTheme = this.currentTheme; + this.currentTheme = theme; + + // Set theme attribute on document element + document.documentElement.setAttribute('data-theme', theme); + + // Update Chart.js defaults if Chart is available + this.updateChartDefaults(theme); + + // Persist theme choice if enabled + if (this.config.persistChoice) { + this.persistTheme(theme); + } + + // Dispatch theme change event + this.dispatchThemeChange(theme, previousTheme); + } + + /** + * Toggle between light and dark themes + */ + toggle(): void { + const nextTheme: Theme = this.currentTheme === 'dark' ? 'light' : 'dark'; + this.apply(nextTheme); + } + + /** + * Get current theme + */ + current(): Theme { + return this.currentTheme; + } + + /** + * Initialize theme system + */ + init(): void { + let initialTheme: Theme = 'light'; + + // Try to load persisted theme + if (this.config.persistChoice) { + const persistedTheme = this.getPersistedTheme(); + if (persistedTheme) { + initialTheme = persistedTheme; + } else if (this.config.autoDetect) { + // Detect OS preference on first visit + initialTheme = this.detectOSPreference(); + } + } + + this.apply(initialTheme); + } + + /** + * Get CSS custom property value + */ + getCSSVar(varName: string): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + } + + /** + * Get vector map theme colors + */ + getVectorMapColors(): VectorMapColors { + return { + backgroundColor: this.getCSSVar('--vmap-bg-color'), + borderColor: this.getCSSVar('--vmap-border-color'), + regionColor: this.getCSSVar('--vmap-region-color'), + markerFill: this.getCSSVar('--vmap-marker-fill'), + markerStroke: this.getCSSVar('--vmap-marker-stroke'), + hoverColor: this.getCSSVar('--vmap-hover-color'), + selectedColor: this.getCSSVar('--vmap-selected-color'), + scaleStart: this.getCSSVar('--vmap-scale-start'), + scaleEnd: this.getCSSVar('--vmap-scale-end'), + scaleLight: this.getCSSVar('--vmap-scale-light'), + scaleDark: this.getCSSVar('--vmap-scale-dark'), + }; + } + + /** + * Get sparkline theme colors + */ + getSparklineColors(): SparklineColors { + return { + success: this.getCSSVar('--sparkline-success'), + purple: this.getCSSVar('--sparkline-purple'), + info: this.getCSSVar('--sparkline-info'), + danger: this.getCSSVar('--sparkline-danger'), + light: this.getCSSVar('--sparkline-light'), + }; + } + + /** + * Get chart theme colors + */ + getChartColors(): ChartThemeColors { + const isDark = this.currentTheme === 'dark'; + return { + textColor: isDark ? '#FFFFFF' : '#212529', + mutedColor: isDark ? '#D1D5DB' : '#6C757D', + borderColor: isDark ? '#374151' : '#E2E5E8', + gridColor: isDark ? 'rgba(209, 213, 219, 0.15)' : 'rgba(0, 0, 0, 0.05)', + tooltipBg: isDark ? '#1F2937' : 'rgba(255, 255, 255, 0.95)', + }; + } + + /** + * Update configuration + */ + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * Get current configuration + */ + getConfig(): ThemeConfig { + return { ...this.config }; + } + + /** + * Private method: Update Chart.js defaults + */ + private updateChartDefaults(theme: Theme): void { + if (!window.Chart || !window.Chart.defaults) { + return; + } + + const isDark = theme === 'dark'; + const colors = this.getChartColors(); + + try { + // Set global defaults + window.Chart.defaults.color = colors.textColor; + window.Chart.defaults.borderColor = colors.borderColor; + window.Chart.defaults.backgroundColor = colors.tooltipBg; + + // Set plugin defaults + if (window.Chart.defaults.plugins?.legend?.labels) { + window.Chart.defaults.plugins.legend.labels.color = colors.textColor; + } + + if (window.Chart.defaults.plugins?.tooltip) { + window.Chart.defaults.plugins.tooltip.backgroundColor = colors.tooltipBg; + window.Chart.defaults.plugins.tooltip.titleColor = colors.textColor; + window.Chart.defaults.plugins.tooltip.bodyColor = colors.textColor; + window.Chart.defaults.plugins.tooltip.borderColor = colors.borderColor; + } + + // Set scale defaults + const scaleDefaults = window.Chart.defaults.scales; + if (scaleDefaults) { + Object.keys(scaleDefaults).forEach(scaleType => { + const scale = scaleDefaults[scaleType]; + if (scale?.ticks) { + scale.ticks.color = colors.mutedColor; + } + if (scale?.grid) { + scale.grid.color = colors.gridColor; + } + if (scale?.pointLabels) { + scale.pointLabels.color = colors.mutedColor; + } + if (scale?.angleLines) { + scale.angleLines.color = colors.gridColor; + } + }); + } + } catch (error) { + console.warn('Error updating Chart.js defaults:', error); + } + } + + /** + * Private method: Persist theme to localStorage + */ + private persistTheme(theme: Theme): void { + try { + localStorage.setItem(THEME_KEY, theme); + } catch (error) { + console.warn('Unable to persist theme:', error); + } + } + + /** + * Private method: Get persisted theme from localStorage + */ + private getPersistedTheme(): Theme | null { + try { + const theme = localStorage.getItem(THEME_KEY) as Theme; + return ['light', 'dark'].includes(theme) ? theme : null; + } catch (error) { + console.warn('Unable to get persisted theme:', error); + return null; + } + } + + /** + * Private method: Detect OS color scheme preference + */ + private detectOSPreference(): Theme { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + return 'light'; + } + + /** + * Private method: Dispatch theme change event + */ + private dispatchThemeChange(theme: Theme, previousTheme: Theme): void { + const event: ThemeChangeEvent = new CustomEvent('adminator:themeChanged', { + detail: { theme, previousTheme }, + }) as ThemeChangeEvent; + + window.dispatchEvent(event); + } +} + +// Create singleton instance +const themeManager = new ThemeManager(); + +// Export legacy object interface for compatibility +export const Theme = { + apply: (theme: Theme) => themeManager.apply(theme), + toggle: () => themeManager.toggle(), + current: () => themeManager.current(), + init: () => themeManager.init(), + getCSSVar: (varName: string) => themeManager.getCSSVar(varName), + getVectorMapColors: () => themeManager.getVectorMapColors(), + getSparklineColors: () => themeManager.getSparklineColors(), + getChartColors: () => themeManager.getChartColors(), +}; + +// Export both the manager instance and legacy interface +export { themeManager as ThemeManager }; +export default Theme; + +// Export types for external use +export type { + Theme as ThemeType, + ThemeConfig, + VectorMapColors, + SparklineColors, + ChartThemeColors, +}; \ No newline at end of file diff --git a/src/assets/scripts/vectorMaps/index.ts b/src/assets/scripts/vectorMaps/index.ts new file mode 100644 index 0000000..0e79419 --- /dev/null +++ b/src/assets/scripts/vectorMaps/index.ts @@ -0,0 +1,542 @@ +/** + * Vector Maps Implementation with TypeScript + * Interactive world map using JSVectorMap with theme support + */ + +import jsVectorMap from 'jsvectormap'; +import 'jsvectormap/dist/jsvectormap.css'; +import 'jsvectormap/dist/maps/world.js'; +import { debounce } from 'lodash'; +import { ThemeManager } from '../utils/theme'; +import type { ComponentInterface } from '../../types'; + +// Type definitions for Vector Maps +export interface VectorMapMarker { + name: string; + coords: [number, number]; + data?: any; +} + +export interface VectorMapColors { + backgroundColor: string; + regionColor: string; + borderColor: string; + hoverColor: string; + selectedColor: string; + markerFill: string; + markerStroke: string; + scaleStart: string; + scaleEnd: string; + textColor: string; +} + +export interface VectorMapOptions { + selector: string; + map: string; + backgroundColor?: string; + regionStyle?: { + initial?: Record; + hover?: Record; + selected?: Record; + }; + markerStyle?: { + initial?: Record; + hover?: Record; + }; + markers?: VectorMapMarker[]; + series?: { + regions?: Array<{ + attribute: string; + scale: [string, string]; + normalizeFunction?: string; + values: Record; + }>; + }; + zoomOnScroll?: boolean; + zoomButtons?: boolean; + onMarkerTooltipShow?: (event: Event, tooltip: any, index: number) => void; + onRegionTooltipShow?: (event: Event, tooltip: any, code: string) => void; + onLoaded?: (map: any) => void; +} + +export interface VectorMapInstance { + destroy(): void; + updateSeries(type: string, config: any): void; + markers?: VectorMapMarker[]; + mapData?: any; + series?: any; +} + +declare global { + interface HTMLElement { + mapInstance?: VectorMapInstance; + } +} + +// Enhanced Vector Map implementation +export class VectorMapComponent implements ComponentInterface { + public name: string = 'VectorMapComponent'; + public element: HTMLElement; + public options: VectorMapOptions; + public isInitialized: boolean = false; + + private mapInstance: VectorMapInstance | null = null; + private container: HTMLElement | null = null; + private resizeObserver: ResizeObserver | null = null; + private themeChangeHandler: (() => void) | null = null; + private resizeHandler: (() => void) | null = null; + private themeManager: typeof ThemeManager; + + constructor(element: HTMLElement, options: Partial = {}) { + this.element = element; + this.options = { + selector: '#vmap', + map: 'world', + backgroundColor: 'transparent', + zoomOnScroll: false, + zoomButtons: false, + markers: [ + { + name: 'INDIA : 350', + coords: [21.00, 78.00], + }, + { + name: 'Australia : 250', + coords: [-33.00, 151.00], + }, + { + name: 'USA : 250', + coords: [36.77, -119.41], + }, + { + name: 'UK : 250', + coords: [55.37, -3.41], + }, + { + name: 'UAE : 250', + coords: [25.20, 55.27], + }, + ], + ...options, + }; + + this.themeManager = ThemeManager; + this.init(); + } + + public init(): void { + this.setupContainer(); + this.setupEventHandlers(); + this.createMap(); + this.isInitialized = true; + } + + public destroy(): void { + this.cleanup(); + this.isInitialized = false; + } + + private setupContainer(): void { + // Remove existing map + const existingMap = document.getElementById('vmap'); + if (existingMap) { + existingMap.remove(); + } + + // Create new map container + this.container = document.createElement('div'); + this.container.id = 'vmap'; + this.container.style.height = '490px'; + this.container.style.position = 'relative'; + this.container.style.overflow = 'hidden'; + this.container.style.borderRadius = '8px'; + this.container.style.border = '1px solid var(--c-border, #d3d9e3)'; + this.container.style.backgroundColor = 'var(--c-bkg-card, #f9fafb)'; + + this.element.appendChild(this.container); + } + + private setupEventHandlers(): void { + // Theme change handler + this.themeChangeHandler = debounce(this.updateMapTheme.bind(this), 150); + window.addEventListener('adminator:themeChanged', this.themeChangeHandler); + + // Resize handler + this.resizeHandler = debounce(this.handleResize.bind(this), 300); + window.addEventListener('resize', this.resizeHandler); + + // Setup ResizeObserver if available + if ('ResizeObserver' in window) { + this.resizeObserver = new ResizeObserver( + debounce(() => { + if (this.mapInstance) { + this.handleResize(); + } + }, 300) + ); + this.resizeObserver.observe(this.element); + } + } + + private createMap(): void { + if (!this.container) return; + + // Destroy existing map instance + this.destroyMapInstance(); + + const colors = this.getThemeColors(); + const mapConfig = this.buildMapConfig(colors); + + try { + this.mapInstance = jsVectorMap(mapConfig); + this.element.mapInstance = this.mapInstance; + } catch (error) { + console.error('VectorMap: Failed to initialize map', error); + this.showFallbackContent(colors); + } + } + + private getThemeColors(): VectorMapColors { + const isDark = this.themeManager.current() === 'dark'; + + return { + backgroundColor: isDark ? '#313644' : '#f9fafb', + regionColor: isDark ? '#565a5c' : '#e6eaf0', + borderColor: isDark ? '#72777a' : '#d3d9e3', + hoverColor: isDark ? '#7774e7' : '#0f9aee', + selectedColor: isDark ? '#37c936' : '#7774e7', + markerFill: isDark ? '#0f9aee' : '#7774e7', + markerStroke: isDark ? '#37c936' : '#0f9aee', + scaleStart: isDark ? '#b9c2d0' : '#e6eaf0', + scaleEnd: isDark ? '#0f9aee' : '#007bff', + textColor: isDark ? '#99abb4' : '#72777a', + }; + } + + private buildMapConfig(colors: VectorMapColors): VectorMapOptions { + return { + selector: '#vmap', + map: 'world', + backgroundColor: this.options.backgroundColor || 'transparent', + + // Region styling + regionStyle: { + initial: { + fill: colors.regionColor, + stroke: colors.borderColor, + 'stroke-width': 1, + 'stroke-opacity': 0.4, + }, + hover: { + fill: colors.hoverColor, + cursor: 'pointer', + }, + selected: { + fill: colors.selectedColor, + }, + ...this.options.regionStyle, + }, + + // Marker styling + markerStyle: { + initial: { + r: 7, + fill: colors.markerFill, + stroke: colors.markerStroke, + 'stroke-width': 2, + 'stroke-opacity': 0.4, + }, + hover: { + r: 10, + fill: colors.hoverColor, + 'stroke-opacity': 0.8, + cursor: 'pointer', + }, + ...this.options.markerStyle, + }, + + // Markers data + markers: this.options.markers || [], + + // Series configuration + series: this.options.series, + + // Interaction options + zoomOnScroll: this.options.zoomOnScroll || false, + zoomButtons: this.options.zoomButtons || false, + + // Event handlers + onMarkerTooltipShow: this.handleMarkerTooltip.bind(this), + onRegionTooltipShow: this.handleRegionTooltip.bind(this), + onLoaded: this.handleMapLoaded.bind(this), + }; + } + + private handleMarkerTooltip(event: Event, tooltip: any, index: number): void { + try { + const marker = this.mapInstance?.markers?.[index]; + const markerName = marker?.name || `Marker ${index + 1}`; + tooltip.text(markerName); + } catch (error) { + console.warn('VectorMap: Error in marker tooltip', error); + } + + // Call custom handler if provided + if (this.options.onMarkerTooltipShow) { + this.options.onMarkerTooltipShow(event, tooltip, index); + } + } + + private handleRegionTooltip(event: Event, tooltip: any, code: string): void { + try { + const mapData = this.mapInstance?.mapData; + const regionName = mapData?.paths?.[code]?.name || code; + const series = this.mapInstance?.series?.regions?.[0]; + const value = series?.values?.[code]; + + const text = value ? `${regionName}: ${value}` : regionName; + tooltip.text(text); + } catch (error) { + console.warn('VectorMap: Error in region tooltip', error); + tooltip.text(code); + } + + // Call custom handler if provided + if (this.options.onRegionTooltipShow) { + this.options.onRegionTooltipShow(event, tooltip, code); + } + } + + private handleMapLoaded(map: any): void { + console.log('VectorMap: Map loaded successfully'); + + // Call custom handler if provided + if (this.options.onLoaded) { + this.options.onLoaded(map); + } + } + + private showFallbackContent(colors: VectorMapColors): void { + if (!this.container) return; + + this.container.innerHTML = ` +
+
+
🗺️
+
World Map
+
Interactive map will load here
+
+
+ `; + } + + private updateMapTheme(): void { + if (!this.mapInstance || !this.container) { + this.createMap(); + return; + } + + const colors = this.getThemeColors(); + + try { + // Update container background + this.container.style.backgroundColor = colors.backgroundColor; + this.container.style.borderColor = colors.borderColor; + + // Update series if available + if (this.mapInstance.series?.regions?.[0]) { + this.mapInstance.updateSeries('regions', { + attribute: 'fill', + scale: [colors.scaleStart, colors.scaleEnd], + values: this.mapInstance.series.regions[0].values || {}, + }); + } + } catch (error) { + console.warn('VectorMap: Theme update failed, reinitializing', error); + this.createMap(); + } + } + + private handleResize(): void { + if (this.mapInstance && this.container) { + // Force a re-render by recreating the map + this.createMap(); + } + } + + private destroyMapInstance(): void { + if (this.mapInstance) { + try { + this.mapInstance.destroy(); + } catch (error) { + console.warn('VectorMap: Error destroying map instance', error); + } + this.mapInstance = null; + } + } + + private cleanup(): void { + this.destroyMapInstance(); + + // Remove event listeners + if (this.themeChangeHandler) { + window.removeEventListener('adminator:themeChanged', this.themeChangeHandler); + this.themeChangeHandler = null; + } + + if (this.resizeHandler) { + window.removeEventListener('resize', this.resizeHandler); + this.resizeHandler = null; + } + + // Disconnect ResizeObserver + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + + // Clear container + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + this.container = null; + } + } + + // Public API methods + public updateMarkers(markers: VectorMapMarker[]): void { + this.options.markers = markers; + this.createMap(); + } + + public updateSeries(type: string, config: any): void { + if (this.mapInstance) { + try { + this.mapInstance.updateSeries(type, config); + } catch (error) { + console.warn('VectorMap: Error updating series', error); + } + } + } + + public getMapInstance(): VectorMapInstance | null { + return this.mapInstance; + } + + public refresh(): void { + this.createMap(); + } + + public updateOptions(newOptions: Partial): void { + this.options = { ...this.options, ...newOptions }; + this.createMap(); + } +} + +// Vector Map Manager +export class VectorMapManager { + private instances: Map = new Map(); + + public initialize(selector: string = '#world-map-marker', options: Partial = {}): VectorMapComponent | null { + const element = document.querySelector(selector); + if (!element) { + // Silently return null if element doesn't exist (normal for pages without maps) + return null; + } + + // Clean up existing instance + const existingInstance = this.instances.get(selector); + if (existingInstance) { + existingInstance.destroy(); + } + + // Create new instance + const vectorMap = new VectorMapComponent(element, options); + this.instances.set(selector, vectorMap); + + return vectorMap; + } + + public getInstance(selector: string): VectorMapComponent | undefined { + return this.instances.get(selector); + } + + public destroyInstance(selector: string): void { + const instance = this.instances.get(selector); + if (instance) { + instance.destroy(); + this.instances.delete(selector); + } + } + + public destroyAll(): void { + this.instances.forEach((instance) => { + instance.destroy(); + }); + this.instances.clear(); + } +} + +// Create singleton manager +const vectorMapManager = new VectorMapManager(); + +// Main initialization function +const vectorMapInit = (): void => { + // Only initialize if the map container exists + if (document.querySelector('#world-map-marker')) { + vectorMapManager.initialize('#world-map-marker', { + markers: [ + { + name: 'INDIA : 350', + coords: [21.00, 78.00], + }, + { + name: 'Australia : 250', + coords: [-33.00, 151.00], + }, + { + name: 'USA : 250', + coords: [36.77, -119.41], + }, + { + name: 'UK : 250', + coords: [55.37, -3.41], + }, + { + name: 'UAE : 250', + coords: [25.20, 55.27], + }, + ], + }); + } +}; + +// Initialize map +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', vectorMapInit); +} else { + vectorMapInit(); +} + +// Cleanup on page unload +window.addEventListener('beforeunload', () => { + vectorMapManager.destroyAll(); +}); + +// Export default for compatibility +export default { + init: vectorMapInit, + manager: vectorMapManager, + VectorMapComponent, + VectorMapManager, +}; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..6714ceb --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,236 @@ +/** + * Core type definitions for Adminator Dashboard + */ + +// Theme types +export type Theme = 'light' | 'dark' | 'auto'; + +export interface ThemeConfig { + theme: Theme; + autoDetect: boolean; + persistChoice: boolean; +} + +// Component types +export interface ComponentOptions { + [key: string]: any; +} + +export interface ComponentInterface { + name: string; + element: HTMLElement; + options: ComponentOptions; + isInitialized: boolean; + init(): void; + destroy(): void; +} + +// Sidebar types +export interface SidebarOptions { + breakpoint?: number; + collapsible?: boolean; + autoHide?: boolean; + animation?: boolean; + animationDuration?: number; +} + +export interface SidebarState { + isCollapsed: boolean; + isMobile: boolean; + activeMenu: string | null; +} + +// Chart types +export type ChartType = 'line' | 'bar' | 'doughnut' | 'pie' | 'radar' | 'scatter' | 'bubble' | 'polarArea'; + +export interface ChartDataset { + label?: string; + data: number[]; + backgroundColor?: string | string[]; + borderColor?: string | string[]; + borderWidth?: number; + fill?: boolean; +} + +export interface ChartData { + labels: string[]; + datasets: ChartDataset[]; +} + +export interface ChartOptions { + type: ChartType; + data: ChartData; + responsive?: boolean; + maintainAspectRatio?: boolean; + plugins?: any; + scales?: any; +} + +// DataTable types +export interface DataTableColumn { + key: string; + title: string; + sortable?: boolean; + searchable?: boolean; + render?: (value: any, row: any) => string; +} + +export interface DataTableOptions { + columns: DataTableColumn[]; + data: any[]; + pageSize?: number; + sortable?: boolean; + searchable?: boolean; + pagination?: boolean; +} + +export interface DataTableState { + currentPage: number; + pageSize: number; + totalRows: number; + sortColumn: string | null; + sortDirection: 'asc' | 'desc'; + searchQuery: string; + filteredData: any[]; +} + +// Date utilities types +export interface DateRange { + start: Date; + end: Date; +} + +export interface DateFormatOptions { + locale?: string; + format?: string; + timeZone?: string; +} + +// DOM utilities types +export type DOMEventHandler = (event: Event) => void; + +export interface DOMUtilities { + select: (selector: string, context?: Element | Document) => HTMLElement | null; + selectAll: (selector: string, context?: Element | Document) => HTMLElement[]; + on: (element: Element | Window | Document, event: string, handler: DOMEventHandler) => void; + off: (element: Element | Window | Document, event: string, handler: DOMEventHandler) => void; + addClass: (element: Element, className: string) => void; + removeClass: (element: Element, className: string) => void; + toggleClass: (element: Element, className: string) => void; + hasClass: (element: Element, className: string) => boolean; + attr: (element: Element, attribute: string, value?: string) => string | void; + data: (element: Element, key: string, value?: any) => any; + ready: (callback: () => void) => void; + exists: (selector: string, context?: Element | Document) => boolean; +} + +// Application state types +export interface ApplicationState { + theme: Theme; + sidebar: SidebarState; + components: Map; + isInitialized: boolean; +} + +export interface ApplicationConfig { + theme: ThemeConfig; + sidebar: SidebarOptions; + enableAnalytics?: boolean; + debugMode?: boolean; +} + +// Event types +export interface CustomEventDetail { + [key: string]: any; +} + +export interface ThemeChangeEvent extends CustomEvent { + detail: { + theme: Theme; + previousTheme: Theme; + }; +} + +export interface ComponentEvent extends CustomEvent { + detail: { + component: string; + action: 'init' | 'destroy' | 'update'; + data?: any; + }; +} + +// Utility types +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + +export type RequiredKeys = T & Required>; + +export type OptionalKeys = Omit & Partial>; + +// Color types +export interface ColorPalette { + primary: string; + secondary: string; + success: string; + danger: string; + warning: string; + info: string; + light: string; + dark: string; +} + +export interface ThemeColors { + light: ColorPalette; + dark: ColorPalette; +} + +// Animation types +export type AnimationEasing = 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'linear'; + +export interface AnimationOptions { + duration?: number; + easing?: AnimationEasing; + delay?: number; + fillMode?: 'none' | 'forwards' | 'backwards' | 'both'; +} + +// Layout types +export interface LayoutBreakpoints { + xs: number; + sm: number; + md: number; + lg: number; + xl: number; + xxl: number; +} + +export interface ResponsiveConfig { + breakpoints: LayoutBreakpoints; + mobileFirst: boolean; +} + +// Error types +export class AdminatorError extends Error { + constructor( + message: string, + public component?: string, + public code?: string + ) { + super(message); + this.name = 'AdminatorError'; + } +} + +// Plugin types +export interface PluginInterface { + name: string; + version: string; + dependencies?: string[]; + init(app: any): void; + destroy(): void; +} + +export interface PluginRegistry { + [key: string]: PluginInterface; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f3d7fd3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,51 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "node", + "allowJs": true, + "checkJs": false, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "removeComments": false, + "importHelpers": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": false, + "baseUrl": "./src", + "paths": { + "@/*": ["*"], + "@/components/*": ["assets/scripts/components/*"], + "@/utils/*": ["assets/scripts/utils/*"], + "@/constants/*": ["assets/scripts/constants/*"] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.spec.ts", + "**/*.test.ts" + ] +} \ No newline at end of file diff --git a/webpack/config.js b/webpack/config.js index 538815f..8c4d6d9 100755 --- a/webpack/config.js +++ b/webpack/config.js @@ -39,11 +39,17 @@ const // --------------- const resolve = { - extensions: ['.webpack-loader.js', '.web-loader.js', '.loader.js', '.js'], + extensions: ['.tsx', '.ts', '.webpack-loader.js', '.web-loader.js', '.loader.js', '.js', '.jsx'], modules: [ path.join(__dirname, '../node_modules'), path.join(manifest.paths.src, ''), ], + alias: { + '@': path.join(manifest.paths.src), + '@/components': path.join(manifest.paths.src, 'assets', 'scripts', 'components'), + '@/utils': path.join(manifest.paths.src, 'assets', 'scripts', 'utils'), + '@/constants': path.join(manifest.paths.src, 'assets', 'scripts', 'constants'), + }, }; const optimization = { diff --git a/webpack/rules/index.js b/webpack/rules/index.js index 16105c6..828388e 100755 --- a/webpack/rules/index.js +++ b/webpack/rules/index.js @@ -1,5 +1,6 @@ module.exports = [ require('./js'), + require('./ts'), require('./images'), require('./css'), require('./sass'), diff --git a/webpack/rules/js.js b/webpack/rules/js.js index 168ded3..54054b3 100755 --- a/webpack/rules/js.js +++ b/webpack/rules/js.js @@ -1,5 +1,5 @@ module.exports = { - test : /\.(js)$/, + test : /\.(js|jsx)$/, exclude : /(node_modules|build|dist\/)/, use : ['babel-loader'], }; diff --git a/webpack/rules/ts.js b/webpack/rules/ts.js new file mode 100644 index 0000000..10f5691 --- /dev/null +++ b/webpack/rules/ts.js @@ -0,0 +1,16 @@ +module.exports = { + test: /\.tsx?$/, + exclude: /(node_modules|build|dist\/)/, + use: [ + { + loader: 'babel-loader', + }, + { + loader: 'ts-loader', + options: { + transpileOnly: true, + experimentalWatchApi: true, + }, + }, + ], +}; \ No newline at end of file