Merge branch 'main' into derek/wallpaperSetup
This commit is contained in:
@@ -8,5 +8,5 @@ trim_trailing_whitespace = true
|
|||||||
indent_size = 4
|
indent_size = 4
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
|
||||||
[*.nix]
|
[*.{nix,yaml,yml}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|||||||
@@ -94,3 +94,5 @@ typings/
|
|||||||
config
|
config
|
||||||
.eslintrc.js
|
.eslintrc.js
|
||||||
!.storybook
|
!.storybook
|
||||||
|
|
||||||
|
src/lib/chrome-extension-toolkit
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
target: './src/shared',
|
target: './src/shared',
|
||||||
from: './',
|
from: './',
|
||||||
except: ['./src/shared', './node_modules'],
|
except: ['./src/shared', './node_modules', './src/lib/chrome-extension-toolkit'],
|
||||||
message: 'You cannot import into `shared` from an external directory.',
|
message: 'You cannot import into `shared` from an external directory.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -227,6 +227,6 @@ module.exports = {
|
|||||||
'simple-import-sort/imports': 'error',
|
'simple-import-sort/imports': 'error',
|
||||||
'simple-import-sort/exports': 'error',
|
'simple-import-sort/exports': 'error',
|
||||||
'import-essentials/restrict-import-depth': 'error',
|
'import-essentials/restrict-import-depth': 'error',
|
||||||
'import-essentials/check-path-alias': 'error',
|
// 'import-essentials/check-path-alias': 'error',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
10
.github/ISSUE_TEMPLATE/bug_report.md
vendored
10
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,18 +1,20 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: "[BUG] "
|
title: '[BUG] '
|
||||||
labels: ''
|
labels: ''
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Pre-submission Checklist**
|
**Pre-submission Checklist**
|
||||||
|
|
||||||
<!-- Please check all applicable boxes before submitting your issue -->
|
<!-- Please check all applicable boxes before submitting your issue -->
|
||||||
|
|
||||||
- [ ] I confirmed this feature isn’t already requested, implemented, or planned (search open issues, pull requests etc)
|
- [ ] I have searched existing issues to make sure this is not a duplicate
|
||||||
- [ ] I have completed all sections below with detailed information
|
- [ ] I have cleared my browser cache and confirmed the issue persists
|
||||||
|
- [ ] I have checked this issue affects the latest version of the extension
|
||||||
|
- [ ] I have disabled other extensions to ensure this isn't a conflict issue
|
||||||
|
- [ ] I have included all the information requested below
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
|
|||||||
8
.github/ISSUE_TEMPLATE/feature_request.md
vendored
8
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,17 +1,19 @@
|
|||||||
---
|
---
|
||||||
name: Feature Request
|
name: Feature Request
|
||||||
about: Suggest an idea for this project
|
about: Suggest an idea for this project
|
||||||
title: "[FEATURE] "
|
title: '[FEATURE] '
|
||||||
labels: feature
|
labels: feature
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Pre-submission Checklist**
|
**Pre-submission Checklist**
|
||||||
|
|
||||||
<!-- Please check all applicable boxes before submitting your feature request -->
|
<!-- Please check all applicable boxes before submitting your feature request -->
|
||||||
|
|
||||||
- [ ] I confirmed this feature isn’t already requested, implemented, or planned (search open issues, pull requests etc)
|
- [ ] I have searched existing issues to ensure this feature hasn't been requested
|
||||||
|
- [ ] I have searched closed issues to check if this was previously rejected/implemented
|
||||||
|
- [ ] I have checked the project roadmap (if available) for planned similar features
|
||||||
|
- [ ] I have reviewed the documentation to confirm this feature doesn't exist
|
||||||
- [ ] I have completed all sections below with detailed information
|
- [ ] I have completed all sections below with detailed information
|
||||||
|
|
||||||
**Your Idea**
|
**Your Idea**
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ about: Updating Build Dependencies
|
|||||||
title: ''
|
title: ''
|
||||||
labels: build, dependencies
|
labels: build, dependencies
|
||||||
assignees: doprz, Razboy20
|
assignees: doprz, Razboy20
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] Updated Nix Flake
|
- [ ] Updated Nix Flake
|
||||||
|
|||||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -15,7 +15,6 @@ updates:
|
|||||||
major-updates:
|
major-updates:
|
||||||
update-types:
|
update-types:
|
||||||
- 'major'
|
- 'major'
|
||||||
|
|
||||||
ignore:
|
ignore:
|
||||||
- dependency-name: '@crxjs/vite-plugin'
|
- dependency-name: '@crxjs/vite-plugin'
|
||||||
- dependency-name: '@unocss/vite'
|
- dependency-name: '@unocss/vite'
|
||||||
|
|||||||
10
.github/workflows/best-practices.yml
vendored
10
.github/workflows/best-practices.yml
vendored
@@ -1,43 +1,33 @@
|
|||||||
name: Best Practices
|
name: Best Practices
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: pnpm run lint
|
run: pnpm run lint
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Run Prettier
|
- name: Run Prettier
|
||||||
run: pnpm run prettier
|
run: pnpm run prettier
|
||||||
|
|||||||
6
.github/workflows/check-types.yml
vendored
6
.github/workflows/check-types.yml
vendored
@@ -1,24 +1,18 @@
|
|||||||
name: Type Check
|
name: Type Check
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
type-check:
|
type-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm run check-types
|
run: pnpm run check-types
|
||||||
|
|||||||
4
.github/workflows/chromatic.yml
vendored
4
.github/workflows/chromatic.yml
vendored
@@ -1,7 +1,5 @@
|
|||||||
name: 'Chromatic'
|
name: 'Chromatic'
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
chromatic:
|
chromatic:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -14,10 +12,8 @@ jobs:
|
|||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Publish to Chromatic
|
- name: Publish to Chromatic
|
||||||
uses: chromaui/action@latest
|
uses: chromaui/action@latest
|
||||||
with:
|
with:
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -15,7 +15,6 @@ jobs:
|
|||||||
- uses: actions/checkout@master
|
- uses: actions/checkout@master
|
||||||
- name: Get file permission
|
- name: Get file permission
|
||||||
run: chmod -R 777 .
|
run: chmod -R 777 .
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Release with semantic-release
|
- name: Release with semantic-release
|
||||||
|
|||||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -1,24 +1,18 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|||||||
3
.github/workflows/validate-pr.yml
vendored
3
.github/workflows/validate-pr.yml
vendored
@@ -1,8 +1,6 @@
|
|||||||
name: Validate PR Title
|
name: Validate PR Title
|
||||||
|
|
||||||
# thank you ben limmer for this workflow:
|
# thank you ben limmer for this workflow:
|
||||||
# https://github.com/blimmer/semantic-release-demo-2/blob/main/.github/workflows/lint-pr.yml
|
# https://github.com/blimmer/semantic-release-demo-2/blob/main/.github/workflows/lint-pr.yml
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
@@ -10,7 +8,6 @@ on:
|
|||||||
- reopened
|
- reopened
|
||||||
- edited
|
- edited
|
||||||
- synchronize
|
- synchronize
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
main:
|
main:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -212,4 +212,9 @@ package-lock.json
|
|||||||
storybook-static/
|
storybook-static/
|
||||||
package/
|
package/
|
||||||
|
|
||||||
.direnv/
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|
||||||
|
# direnv
|
||||||
|
.direnv
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
|||||||
'@shared': resolve(root, 'shared'),
|
'@shared': resolve(root, 'shared'),
|
||||||
'@background': resolve(pagesDir, 'background'),
|
'@background': resolve(pagesDir, 'background'),
|
||||||
'@views': resolve(root, 'views'),
|
'@views': resolve(root, 'views'),
|
||||||
|
'@chrome-extension-toolkit': resolve(root, 'lib/chrome-extension-toolkit'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -6,13 +6,9 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Run current script",
|
"name": "Run current script",
|
||||||
"runtimeExecutable": "npx",
|
"runtimeExecutable": "npx",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": ["tsx"],
|
||||||
"tsx"
|
|
||||||
],
|
|
||||||
"program": "${file}",
|
"program": "${file}",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"]
|
||||||
"<node_internals>/**"
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -26,7 +26,7 @@
|
|||||||
"navigation": "Routes",
|
"navigation": "Routes",
|
||||||
"logging": "log",
|
"logging": "log",
|
||||||
"popup": "Layout",
|
"popup": "Layout",
|
||||||
"storage": "Database",
|
"storage": "Database"
|
||||||
},
|
},
|
||||||
"material-icon-theme.files.associations": {
|
"material-icon-theme.files.associations": {
|
||||||
"tsconfig.extension.json": "tsconfig",
|
"tsconfig.extension.json": "tsconfig",
|
||||||
@@ -36,5 +36,5 @@
|
|||||||
"[html]": {
|
"[html]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
}
|
}
|
||||||
|
|||||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,3 +1,18 @@
|
|||||||
|
## [2.3.0](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.2...v2.3.0) (2026-01-07)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add drag-and-drop import for schedules ([#661](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/661)) ([549c52a](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/549c52a39fee718f2bb07cfce33a294835a2246b)), closes [#446](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/446)
|
||||||
|
* allow bypassing the 10-schedule limit ([#675](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/675)) ([6a67a32](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/6a67a32e4f50a5bdd20aa43789f199b822483e2d))
|
||||||
|
* condense resourceLinks course schedule ([#676](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/676)) ([cee5f02](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/cee5f0284f09f39ca5ae64559d0b697646c77e74))
|
||||||
|
* LHD birthday ([#717](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/717)) ([2d18553](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2d18553f98c5146fa18699ae20462e7dcbc9d35c))
|
||||||
|
* **nix:** add prettier-version-match check ([#713](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/713)) ([8ccf7fb](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/8ccf7fb37e769ba445f39c140ca9c1c4245cc1c1))
|
||||||
|
* **nix:** build UTRP ([#714](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/714)) ([38bb29b](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/38bb29b20b97ed3cf8fd6511df16553fed1d58bb))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* .editorconfig syntax for nix files ([b406d4d](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/b406d4dd244a25688c2b9621cf5d441228bd8913))
|
||||||
|
* toSorted outdated chrome bug ([#694](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/694)) ([4f5d8c6](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/4f5d8c6d20e3cfeb7b62520ba1819e297d2cc60f))
|
||||||
## [2.2.2](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.1...v2.2.2) (2025-10-13)
|
## [2.2.2](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.1...v2.2.2) (2025-10-13)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -6,6 +21,7 @@
|
|||||||
* automatically select new or duplicated schedules ([#583](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/583)) ([#589](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/589)) ([2a50f55](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2a50f5580d3dbeb0d66546c23cf29bbb37d80da2))
|
* automatically select new or duplicated schedules ([#583](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/583)) ([#589](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/589)) ([2a50f55](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2a50f5580d3dbeb0d66546c23cf29bbb37d80da2))
|
||||||
* **env:** add SENTRY env vars ([8f7e1bc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/8f7e1bc0af6336549068e02b80df21d4e8f4ef9c))
|
* **env:** add SENTRY env vars ([8f7e1bc](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/8f7e1bc0af6336549068e02b80df21d4e8f4ef9c))
|
||||||
* export schedule button add to calendar ([#594](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/594)) ([5994ded](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/5994ded8be876cb55174d27d3fdb0832b21a0ff9))
|
* export schedule button add to calendar ([#594](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/594)) ([5994ded](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/5994ded8be876cb55174d27d3fdb0832b21a0ff9))
|
||||||
|
* **release:** v2.2.2 ([c21cbd7](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/c21cbd77f0764c03a711589ff4f957cb8c936eec))
|
||||||
* search result shading ([#617](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/617)) ([be861b8](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/be861b823cb2cb7f6f4a1f266351eec3fc1c2f99))
|
* search result shading ([#617](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/617)) ([be861b8](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/be861b823cb2cb7f6f4a1f266351eec3fc1c2f99))
|
||||||
* show warning for courses of different semesters ([#570](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/570)) ([2e7dac1](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2e7dac1e3eba757231ac07ac966231c08c703a16))
|
* show warning for courses of different semesters ([#570](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/570)) ([2e7dac1](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2e7dac1e3eba757231ac07ac966231c08c703a16))
|
||||||
* support summer grades, fix summer course parser ([#596](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/596)) ([2d92dd4](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2d92dd47f00a44b7d48e92a8ffba94480e4e73f9))
|
* support summer grades, fix summer course parser ([#596](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/596)) ([2d92dd4](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/2d92dd47f00a44b7d48e92a8ffba94480e4e73f9))
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ If you believe someone is violating the code of conduct, we ask that you report
|
|||||||
- **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
|
- **Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language.
|
||||||
- **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the UT Registration Plus community should be respectful when dealing with other members as well as with people outside the UT Registration Plus community.
|
- **Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the UT Registration Plus community should be respectful when dealing with other members as well as with people outside the UT Registration Plus community.
|
||||||
- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
|
- **Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to:
|
||||||
- Violent threats or language directed against another person.
|
- Violent threats or language directed against another person.
|
||||||
- Discriminatory jokes and language.
|
- Discriminatory jokes and language.
|
||||||
- Posting sexually explicit or violent material.
|
- Posting sexually explicit or violent material.
|
||||||
- Posting (or threatening to post) other people's personally identifying information ("doxing").
|
- Posting (or threatening to post) other people's personally identifying information ("doxing").
|
||||||
- Personal insults, especially those using racist or sexist terms.
|
- Personal insults, especially those using racist or sexist terms.
|
||||||
- Unwelcome sexual attention.
|
- Unwelcome sexual attention.
|
||||||
- Advocating for, or encouraging, any of the above behavior.
|
- Advocating for, or encouraging, any of the above behavior.
|
||||||
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
|
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
|
||||||
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and UT Registration Plus is no exception. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of UT Registration Plus comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
|
- **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and UT Registration Plus is no exception. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of UT Registration Plus comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes.
|
||||||
|
|
||||||
Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html).
|
Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html).
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ Special thanks to the developers and contributors behind these amazing tools and
|
|||||||
|
|
||||||
## Activity
|
## Activity
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
|
|||||||
10
default.nix
Normal file
10
default.nix
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
(import (
|
||||||
|
let
|
||||||
|
rev = "v1.1.0";
|
||||||
|
sha256 = "sha256:19d2z6xsvpxm184m41qrpi1bplilwipgnzv9jy17fgw421785q1m";
|
||||||
|
in
|
||||||
|
fetchTarball {
|
||||||
|
inherit sha256;
|
||||||
|
url = "https://github.com/NixOS/flake-compat/archive/${rev}.tar.gz";
|
||||||
|
}
|
||||||
|
) { src = ./.; }).defaultNix
|
||||||
@@ -24,7 +24,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate the mode
|
# Validate the mode
|
||||||
if [[ ! " ${SUPPORTED_MODES[*]} " =~ " ${mode} " ]]; then
|
if [[ ! " ${SUPPORTED_MODES[*]} " =~ ${mode} ]]; then
|
||||||
echo "Error: Invalid mode '${mode}'" >&2
|
echo "Error: Invalid mode '${mode}'" >&2
|
||||||
usage
|
usage
|
||||||
fi
|
fi
|
||||||
|
|||||||
87
flake.lock
generated
87
flake.lock
generated
@@ -1,30 +1,30 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1731533236,
|
"lastModified": 1767609335,
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
"narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=",
|
||||||
"owner": "numtide",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-parts",
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
"rev": "250481aafeb741edfe23d29195671c19b36b6dca",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "numtide",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-parts",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1759831965,
|
"lastModified": 1767640445,
|
||||||
"narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
|
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "c9b6fb798541223bbb396d287d16f43520250518",
|
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -34,24 +34,59 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"nixpkgs-lib": {
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1681028828,
|
"lastModified": 1765674936,
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
"narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=",
|
||||||
"owner": "nix-systems",
|
"owner": "nix-community",
|
||||||
"repo": "default",
|
"repo": "nixpkgs.lib",
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
"rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-systems",
|
"owner": "nix-community",
|
||||||
"repo": "default",
|
"repo": "nixpkgs.lib",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1761236834,
|
||||||
|
"narHash": "sha256-+pthv6hrL5VLW2UqPdISGuLiUZ6SnAXdd2DdUE+fV2Q=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "d5faa84122bc0a1fd5d378492efce4e289f8eac1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"treefmt-nix": "treefmt-nix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"treefmt-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767468822,
|
||||||
|
"narHash": "sha256-MpffQxHxmjVKMiQd0Tg2IM/bSjjdQAM+NDcX6yxj7rE=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"rev": "d56486eb9493ad9c4777c65932618e9c2d0468fc",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
flake.nix
50
flake.nix
@@ -1,43 +1,33 @@
|
|||||||
{
|
{
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
|
treefmt-nix.url = "github:numtide/treefmt-nix";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{
|
inputs@{ flake-parts, ... }:
|
||||||
self,
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
nixpkgs,
|
systems = inputs.nixpkgs.lib.systems.flakeExposed;
|
||||||
flake-utils,
|
|
||||||
}:
|
|
||||||
flake-utils.lib.eachDefaultSystem (
|
|
||||||
system:
|
|
||||||
let
|
|
||||||
pkgs = (import nixpkgs { inherit system; });
|
|
||||||
|
|
||||||
commonPackages = with pkgs; [
|
imports = [
|
||||||
nodejs_20 # v20.19.5
|
./nix/packages.nix
|
||||||
pnpm_10 # v10.18.0
|
./nix/devShells.nix
|
||||||
|
./nix/treefmt.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
additionalPackages = with pkgs; [
|
perSystem =
|
||||||
bun
|
{ system, ... }:
|
||||||
nodePackages.conventional-changelog-cli
|
|
||||||
sentry-cli
|
|
||||||
];
|
|
||||||
in
|
|
||||||
{
|
{
|
||||||
formatter = pkgs.nixfmt-rfc-style;
|
_module.args.pkgs = import inputs.nixpkgs {
|
||||||
|
inherit system;
|
||||||
devShells.default = pkgs.mkShell {
|
overlays = [
|
||||||
name = "utrp-dev";
|
(final: prev: {
|
||||||
buildInputs = commonPackages;
|
nodejs = prev.nodejs_20; # v20.19.5
|
||||||
|
})
|
||||||
|
];
|
||||||
|
config = { };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.full = pkgs.mkShell {
|
|
||||||
name = "utrp-dev-full";
|
|
||||||
buildInputs = commonPackages ++ additionalPackages;
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
30
nix/devShells.nix
Normal file
30
nix/devShells.nix
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
perSystem =
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
commonPackages = with pkgs; [
|
||||||
|
nodejs # Defined in overlay
|
||||||
|
pnpm_10 # v10.18.2
|
||||||
|
];
|
||||||
|
|
||||||
|
additionalPackages = with pkgs; [
|
||||||
|
bun
|
||||||
|
nodePackages.conventional-changelog-cli
|
||||||
|
sentry-cli
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
name = "utrp-dev";
|
||||||
|
packages = commonPackages;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.full = pkgs.mkShell {
|
||||||
|
name = "utrp-dev-full";
|
||||||
|
packages = commonPackages ++ additionalPackages;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
51
nix/package.nix
Normal file
51
nix/package.nix
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
stdenv,
|
||||||
|
lib,
|
||||||
|
nodejs,
|
||||||
|
pnpm_10,
|
||||||
|
git,
|
||||||
|
version ? "dev",
|
||||||
|
gitRev ? "unknown",
|
||||||
|
gitBranch ? "unknown",
|
||||||
|
buildScript ? "build",
|
||||||
|
}:
|
||||||
|
|
||||||
|
stdenv.mkDerivation (finalAttrs: {
|
||||||
|
inherit version;
|
||||||
|
pname = "ut-registration-plus";
|
||||||
|
|
||||||
|
src = ../.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
nodejs
|
||||||
|
pnpm_10.configHook
|
||||||
|
git
|
||||||
|
];
|
||||||
|
|
||||||
|
pnpmDeps = pnpm_10.fetchDeps {
|
||||||
|
inherit (finalAttrs) pname version src;
|
||||||
|
fetcherVersion = 2;
|
||||||
|
hash = "sha256-UqHymJWvlTV4glra/6DkxuCxbG5dpPkFcnvq3vuxsJ8=";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Pass git info to the build
|
||||||
|
VITE_GIT_COMMIT = gitRev;
|
||||||
|
VITE_GIT_BRANCH = gitBranch;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
pnpm run ${buildScript}
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out
|
||||||
|
cp -r dist/* $out/
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "UT Registration Plus";
|
||||||
|
homepage = "https://github.com/Longhorn-Developers/UT-Registration-Plus";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
maintainers = lib.maintainers.doprz;
|
||||||
|
platforms = lib.platforms.unix;
|
||||||
|
};
|
||||||
|
})
|
||||||
40
nix/packages.nix
Normal file
40
nix/packages.nix
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{ inputs, ... }:
|
||||||
|
{
|
||||||
|
perSystem =
|
||||||
|
{ pkgs, ... }:
|
||||||
|
let
|
||||||
|
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
||||||
|
gitRev = inputs.self.shortRev or inputs.self.dirtyShortRev or "dev";
|
||||||
|
gitBranch = if inputs.self ? ref then inputs.self.ref else "unknown";
|
||||||
|
baseVersion = packageJson.version;
|
||||||
|
|
||||||
|
commonArgs = {
|
||||||
|
inherit gitRev gitBranch;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Prod variant
|
||||||
|
ut-registration-plus = pkgs.callPackage ./package.nix (
|
||||||
|
commonArgs
|
||||||
|
// {
|
||||||
|
version = "${baseVersion}+git.${gitRev}";
|
||||||
|
buildScript = "build";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
# Dev variant
|
||||||
|
ut-registration-plus-dev = pkgs.callPackage ./package.nix (
|
||||||
|
commonArgs
|
||||||
|
// {
|
||||||
|
version = "${baseVersion}-dev+git.${gitRev}";
|
||||||
|
buildScript = "build:dev";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = {
|
||||||
|
inherit ut-registration-plus ut-registration-plus-dev;
|
||||||
|
default = ut-registration-plus;
|
||||||
|
dev = ut-registration-plus-dev;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
63
nix/treefmt.nix
Normal file
63
nix/treefmt.nix
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{ inputs, ... }:
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
inputs.treefmt-nix.flakeModule
|
||||||
|
];
|
||||||
|
|
||||||
|
perSystem =
|
||||||
|
{ pkgs, ... }:
|
||||||
|
{
|
||||||
|
treefmt = {
|
||||||
|
projectRootFile = "flake.nix";
|
||||||
|
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler;
|
||||||
|
programs.nixfmt.package = pkgs.nixfmt-rfc-style;
|
||||||
|
|
||||||
|
# NOTE: Make sure the prettier version in package.json and the one used by treefmt are the same for consistent results
|
||||||
|
programs.prettier.enable = true;
|
||||||
|
programs.shellcheck.enable = true;
|
||||||
|
programs.yamlfmt.enable = true;
|
||||||
|
programs.dockerfmt.enable = true;
|
||||||
|
|
||||||
|
settings.formatter.prettier.excludes = [ "pnpm-lock.yaml" ];
|
||||||
|
settings.formatter.shellcheck.excludes = [ ".envrc" ];
|
||||||
|
settings.formatter.yamlfmt.excludes = [ "pnpm-lock.yaml" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
checks = {
|
||||||
|
prettier-version-match =
|
||||||
|
pkgs.runCommand "check-prettier-version"
|
||||||
|
{
|
||||||
|
buildInputs = [ pkgs.jq ];
|
||||||
|
}
|
||||||
|
''
|
||||||
|
# Extract prettier version from package.json
|
||||||
|
packageJsonVersion=$(jq -r '.devDependencies.prettier // empty' ${../package.json})
|
||||||
|
|
||||||
|
if [ -z "$packageJsonVersion" ]; then
|
||||||
|
echo "Error: prettier not found in package.json devDependencies"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove any semver prefix characters (^, ~, etc...)
|
||||||
|
packageJsonVersion=$(echo "$packageJsonVersion" | sed 's/^[\^~>=<]*//')
|
||||||
|
|
||||||
|
# Get prettier version from nixpkgs
|
||||||
|
nixVersion="${pkgs.nodePackages.prettier.version}"
|
||||||
|
|
||||||
|
if [ "$packageJsonVersion" != "$nixVersion" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "ERROR: Prettier version mismatch!"
|
||||||
|
echo " package.json: $packageJsonVersion"
|
||||||
|
echo " nixpkgs: $nixVersion"
|
||||||
|
echo ""
|
||||||
|
echo "Please update one of the following:"
|
||||||
|
echo " - Update prettier in package.json to match nixpkgs: $nixVersion"
|
||||||
|
echo " - Override prettier in your flake to match package.json"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
14
package.json
14
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ut-registration-plus",
|
"name": "ut-registration-plus",
|
||||||
"displayName": "UT Registration Plus",
|
"displayName": "UT Registration Plus",
|
||||||
"version": "2.2.2",
|
"version": "2.3.0",
|
||||||
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
|
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
|
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
|
||||||
@@ -39,9 +39,11 @@
|
|||||||
"@phosphor-icons/react": "^2.1.7",
|
"@phosphor-icons/react": "^2.1.7",
|
||||||
"@sentry/react": "^8.55.0",
|
"@sentry/react": "^8.55.0",
|
||||||
"@tanstack/react-query": "^5.69.0",
|
"@tanstack/react-query": "^5.69.0",
|
||||||
|
"@tsparticles/engine": "^3.9.1",
|
||||||
|
"@tsparticles/react": "^3.0.0",
|
||||||
|
"@tsparticles/slim": "^3.9.1",
|
||||||
"@unocss/vite": "^0.63.6",
|
"@unocss/vite": "^0.63.6",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"chrome-extension-toolkit": "^0.0.54",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"conventional-changelog": "^6.0.0",
|
"conventional-changelog": "^6.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -134,9 +136,10 @@
|
|||||||
"gulp": "^5.0.0",
|
"gulp": "^5.0.0",
|
||||||
"gulp-execa": "^7.0.1",
|
"gulp-execa": "^7.0.1",
|
||||||
"gulp-zip": "^6.1.0",
|
"gulp-zip": "^6.1.0",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "3.6.2",
|
||||||
"react-dev-utils": "^12.0.1",
|
"react-dev-utils": "^12.0.1",
|
||||||
"semantic-release": "^24.2.3",
|
"semantic-release": "^24.2.3",
|
||||||
"storybook": "^8.6.0",
|
"storybook": "^8.6.0",
|
||||||
@@ -159,7 +162,10 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"es-module-lexer": "^1.5.4"
|
"es-module-lexer": "^1.5.4"
|
||||||
}
|
},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@tsparticles/engine"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "20.19.4",
|
"node": "20.19.4",
|
||||||
|
|||||||
863
pnpm-lock.yaml
generated
863
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
10
shell.nix
Normal file
10
shell.nix
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
(import (
|
||||||
|
let
|
||||||
|
rev = "v1.1.0";
|
||||||
|
sha256 = "sha256:19d2z6xsvpxm184m41qrpi1bplilwipgnzv9jy17fgw421785q1m";
|
||||||
|
in
|
||||||
|
fetchTarball {
|
||||||
|
inherit sha256;
|
||||||
|
url = "https://github.com/NixOS/flake-compat/archive/${rev}.tar.gz";
|
||||||
|
}
|
||||||
|
) { src = ./.; }).shellNix
|
||||||
8
src/lib/chrome-extension-toolkit/README.md
Normal file
8
src/lib/chrome-extension-toolkit/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# chrome-extension-toolkit
|
||||||
|
|
||||||
|
Repo: https://github.com/sghsri/chrome-extension-toolkit
|
||||||
|
Author: sghsri
|
||||||
|
License: MIT
|
||||||
|
Version: 0.0.96
|
||||||
|
|
||||||
|
This library has been inlined into UTRP due to the package being removed from npm.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '8px',
|
||||||
|
fontSize: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderTopLeftRadius: '20px',
|
||||||
|
borderTopRightRadius: '20px',
|
||||||
|
zIndex: 999999999999,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeButtonStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '8px',
|
||||||
|
paddingRight: '20px',
|
||||||
|
fontSize: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that displays a message onto a content script when the context extension is invalidated.
|
||||||
|
*/
|
||||||
|
export function ContextInvalidated(props: Props): JSX.Element | null {
|
||||||
|
const [isShowing, setIsShowing] = useState(true);
|
||||||
|
|
||||||
|
const hide = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsShowing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return isShowing ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...containerStyle,
|
||||||
|
}}
|
||||||
|
id='extension-context-invalidated'
|
||||||
|
className={props.className}
|
||||||
|
onClick={props.onClick ?? reload}
|
||||||
|
>
|
||||||
|
Context Extension Context invalidated. Click to reload
|
||||||
|
<div style={closeButtonStyle} onClick={hide}>
|
||||||
|
✕
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
2
src/lib/chrome-extension-toolkit/context/index.ts
Normal file
2
src/lib/chrome-extension-toolkit/context/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ContextInvalidated';
|
||||||
|
export * from './onContextInvalidated';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* A content script can be invalidated if a chrome extension is reloaded / updated.
|
||||||
|
* This function is used to detect when the extension's context has been invalidated, and to call a callback.
|
||||||
|
* @param callback A callback to be called when the extension's context has been invalidated
|
||||||
|
*/
|
||||||
|
export function onContextInvalidated(callback: () => void) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// this means the current tab's context has been invalidated
|
||||||
|
if (!chrome.runtime.id) {
|
||||||
|
clearInterval(interval);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}, 1 * 1000);
|
||||||
|
}
|
||||||
55
src/lib/chrome-extension-toolkit/dom/createShadowRoot.ts
Normal file
55
src/lib/chrome-extension-toolkit/dom/createShadowRoot.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* An extension of HTMLDivElement that represents a shadow root for use within an Extension Content Script.
|
||||||
|
*/
|
||||||
|
interface HTMLShadowDOMElement extends HTMLDivElement {
|
||||||
|
shadowRoot: ShadowRoot & {
|
||||||
|
INJECTION_POINT: HTMLDivElement;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Adds a style sheet to the shadow root.
|
||||||
|
* @param path the path to the style sheet relative to the extension's root directory. uses chrome.runtime.getURL to get the absolute path.
|
||||||
|
*/
|
||||||
|
addStyle(path: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In extension content scripts, often times the parent site's styles will override the styles of the extension.
|
||||||
|
* To get around this, we create a shadow DOM and isolate the extension's html and styles in the shadow DOM.
|
||||||
|
* from the parent site's styles to prevent conflicts.
|
||||||
|
* @param id the id of the shadow root.
|
||||||
|
* @param options the optional options for the shadow root.
|
||||||
|
* @param isolate whether or not to isolate the extension's document flow from the parent site's document flow.
|
||||||
|
* @returns A Div that represents the shadow root with some additional methods added to it.
|
||||||
|
*/
|
||||||
|
export function createShadowDOM(id: string, options?: ShadowRootInit, isolate = false): HTMLShadowDOMElement {
|
||||||
|
const html = document.querySelector('html');
|
||||||
|
if (!html) {
|
||||||
|
throw new Error('Could not find html element');
|
||||||
|
}
|
||||||
|
const div = document.createElement('div') as HTMLShadowDOMElement;
|
||||||
|
div.id = id;
|
||||||
|
div.style.all = 'initial';
|
||||||
|
div.attachShadow({
|
||||||
|
mode: 'open',
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const INJECTION_POINT = document.createElement('div');
|
||||||
|
INJECTION_POINT.id = 'INJECTION_POINT';
|
||||||
|
div.shadowRoot.appendChild(INJECTION_POINT);
|
||||||
|
div.shadowRoot.INJECTION_POINT = INJECTION_POINT;
|
||||||
|
|
||||||
|
div.addStyle = async (path: string) => {
|
||||||
|
const style = await fetch(chrome.runtime.getURL(path));
|
||||||
|
const styleNode = document.createElement('style');
|
||||||
|
const parsedStyle = await style.text();
|
||||||
|
styleNode.textContent = parsedStyle;
|
||||||
|
div.shadowRoot.appendChild(styleNode);
|
||||||
|
};
|
||||||
|
|
||||||
|
html.appendChild(div);
|
||||||
|
|
||||||
|
if (isolate) document.body.style.isolation = 'isolate';
|
||||||
|
|
||||||
|
return div as HTMLShadowDOMElement;
|
||||||
|
}
|
||||||
1
src/lib/chrome-extension-toolkit/dom/index.ts
Normal file
1
src/lib/chrome-extension-toolkit/dom/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './createShadowRoot';
|
||||||
70
src/lib/chrome-extension-toolkit/getScriptType.ts
Normal file
70
src/lib/chrome-extension-toolkit/getScriptType.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Possible contexts in which a chrome extension can run.
|
||||||
|
*/
|
||||||
|
export enum ScriptType {
|
||||||
|
CONTENT_SCRIPT = 'content_script',
|
||||||
|
BACKGROUND_SCRIPT = 'background_script',
|
||||||
|
EXTENSION_POPUP = 'extension_popup',
|
||||||
|
EXTENSION_PAGE = 'extension_page',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome extension code can run in different contexts.
|
||||||
|
* These different contexts have different capabilities and access to certain parts of the chrome extensions API.
|
||||||
|
* For example, the chrome.tabs api is not readily available in the content scripts.
|
||||||
|
* This function is used to identify the context in which the code is running.
|
||||||
|
* @returns The context in which the code is running, or null if the code is not running in a chrome extension.
|
||||||
|
*/
|
||||||
|
export default function getScriptType(): ScriptType | null {
|
||||||
|
if (!chrome?.runtime?.id) {
|
||||||
|
// we are not in a chrome extension
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const manifest = chrome.runtime.getManifest();
|
||||||
|
if (globalThis.window === undefined) {
|
||||||
|
return ScriptType.BACKGROUND_SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.href.startsWith(`chrome-extension://${chrome.runtime.id}`)) {
|
||||||
|
if (manifest.action?.default_popup && window.location.href.includes(manifest.action.default_popup)) {
|
||||||
|
return ScriptType.EXTENSION_POPUP;
|
||||||
|
}
|
||||||
|
return ScriptType.EXTENSION_PAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScriptType.CONTENT_SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper function to check if the code is running in a content script.
|
||||||
|
* @returns true if the code is running in a content script, false otherwise.
|
||||||
|
*/
|
||||||
|
export function isContentScript(): boolean {
|
||||||
|
return getScriptType() === ScriptType.CONTENT_SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper function to check if the code is running in the background script.
|
||||||
|
* @returns true if the code is running in the background script, false otherwise.
|
||||||
|
*/
|
||||||
|
export function isBackgroundScript(): boolean {
|
||||||
|
return getScriptType() === ScriptType.BACKGROUND_SCRIPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper function to check if the code is running in the extension popup.
|
||||||
|
* @returns true if the code is running in the extension popup, false otherwise.
|
||||||
|
*/
|
||||||
|
export function isExtensionPopup(): boolean {
|
||||||
|
return getScriptType() === ScriptType.EXTENSION_POPUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper function to check if the code is running in an extension page (popup, options, etc.).
|
||||||
|
* @returns true if the code is running in an extension page (popup, options, etc.), false otherwise.
|
||||||
|
* @param pageName The name of the page to check for. ex: 'options.html'
|
||||||
|
*/
|
||||||
|
export function isExtensionPage(pageName?: string): boolean {
|
||||||
|
const isPage = getScriptType() === ScriptType.EXTENSION_PAGE;
|
||||||
|
return isPage && pageName ? window.location.href.includes(pageName) : isPage;
|
||||||
|
}
|
||||||
6
src/lib/chrome-extension-toolkit/index.ts
Normal file
6
src/lib/chrome-extension-toolkit/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './context';
|
||||||
|
export * from './dom';
|
||||||
|
export * from './getScriptType';
|
||||||
|
export * from './messaging';
|
||||||
|
export * from './storage';
|
||||||
|
export * from './types';
|
||||||
127
src/lib/chrome-extension-toolkit/messaging/MessageListener.ts
Normal file
127
src/lib/chrome-extension-toolkit/messaging/MessageListener.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import getScriptType, { ScriptType } from '../getScriptType';
|
||||||
|
import type { IMessageListener, Message, MessageHandler, Serializable } from '../types';
|
||||||
|
import { MessageEndpoint } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for configuring a message listener.
|
||||||
|
*/
|
||||||
|
export interface MessageListenerOptions {
|
||||||
|
/**
|
||||||
|
* A callback function that will be called when an error occurs.
|
||||||
|
* Useful if you want to log errors to a service like Sentry or Bugsnag.
|
||||||
|
* @param error The error that occurred.
|
||||||
|
*/
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flag indicating whether verbose logging should be enabled.
|
||||||
|
*/
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that can be used to listen for and handle messages coming from another extension context.
|
||||||
|
*/
|
||||||
|
export class MessageListener<M> implements IMessageListener<M> {
|
||||||
|
private handlers: MessageHandler<M>;
|
||||||
|
private scriptType: ScriptType;
|
||||||
|
private myEndpoint: MessageEndpoint;
|
||||||
|
private listeningFor: MessageEndpoint;
|
||||||
|
|
||||||
|
private onError?: (error: Error) => void;
|
||||||
|
|
||||||
|
private isVerbose: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that can be used to listen for and handle messages coming from another extension context.
|
||||||
|
* @param handlers the message handlers for the messages that this listener will handle. When a message is received, the corresponding message handler is called.
|
||||||
|
*/
|
||||||
|
constructor(handlers: MessageHandler<M>) {
|
||||||
|
this.handlers = handlers;
|
||||||
|
|
||||||
|
// we want to know what type of script we are running in so we can determine what endpoint we are (background or foreground)
|
||||||
|
const scriptType = getScriptType();
|
||||||
|
if (!scriptType) {
|
||||||
|
throw new Error('[crx-kit]: Unable to determine extension script type.');
|
||||||
|
}
|
||||||
|
this.scriptType = scriptType;
|
||||||
|
|
||||||
|
if (this.scriptType === ScriptType.BACKGROUND_SCRIPT) {
|
||||||
|
this.myEndpoint = MessageEndpoint.BACKGROUND;
|
||||||
|
this.listeningFor = MessageEndpoint.FOREGROUND;
|
||||||
|
} else {
|
||||||
|
this.myEndpoint = MessageEndpoint.FOREGROUND;
|
||||||
|
this.listeningFor = MessageEndpoint.BACKGROUND;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage = (
|
||||||
|
message: Message<M>,
|
||||||
|
sender: chrome.runtime.MessageSender,
|
||||||
|
sendResponse: (response: any) => void
|
||||||
|
): boolean => {
|
||||||
|
if (message.to !== this.myEndpoint && message.from !== this.listeningFor) {
|
||||||
|
// this message is not for my current context, so ignore it
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const messageName = message.name as string;
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
const handler = this.handlers[messageName];
|
||||||
|
if (!handler) {
|
||||||
|
// this message is for my current context, but I don't have a handler for it, so ignore it
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (this.isVerbose) {
|
||||||
|
console.log(`[crx-kit]: message received: ${messageName}`, {
|
||||||
|
name: messageName,
|
||||||
|
data: message.data,
|
||||||
|
sender,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// this message is for my current context, and I have a handler for it, so handle it
|
||||||
|
handler({
|
||||||
|
data: message.data as Serializable<typeof message.data>,
|
||||||
|
sendResponse,
|
||||||
|
sender,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[crx-kit]: Error handling message ${messageName}`, {
|
||||||
|
name: messageName,
|
||||||
|
error,
|
||||||
|
message,
|
||||||
|
sender,
|
||||||
|
});
|
||||||
|
if (this.onError) {
|
||||||
|
// @ts-expect-error
|
||||||
|
this.onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for messages from the specified source.
|
||||||
|
* @param options - The options for the message listener.
|
||||||
|
*/
|
||||||
|
public listen(options: MessageListenerOptions = { verbose: false }) {
|
||||||
|
this.isVerbose = options.verbose ?? false;
|
||||||
|
this.onError = options.onError;
|
||||||
|
console.log(`[crx-kit]: ${this.toString()} listening for messages from ${this.listeningFor}`);
|
||||||
|
chrome.runtime.onMessage.addListener(this.handleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops listening for messages
|
||||||
|
*/
|
||||||
|
public unlisten() {
|
||||||
|
console.log(`[crx-kit]: ${this.toString()} no longer listening for messages from ${this.listeningFor}`);
|
||||||
|
chrome.runtime.onMessage.removeListener(this.handleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toString() {
|
||||||
|
return `MessageListener(${this.myEndpoint})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Message, MessageData, MessageResponse } from '../types';
|
||||||
|
import { MessageEndpoint } from '../types';
|
||||||
|
/**
|
||||||
|
* An object that can be used to send messages to the background script.
|
||||||
|
*/ export type BackgroundMessenger<M> = {
|
||||||
|
[K in keyof M]: MessageData<M, K> extends undefined
|
||||||
|
? () => Promise<MessageResponse<M, K>>
|
||||||
|
: (data: MessageData<M, K>) => Promise<MessageResponse<M, K>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the foreground message is being sent to specifically (which tab or frame)
|
||||||
|
*/
|
||||||
|
type ForegroundMessageOptions =
|
||||||
|
| {
|
||||||
|
tabId: number;
|
||||||
|
frameId?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tabId: 'ALL' | 'ACTIVE_TAB';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* an object that can be used to send messages to the foreground (tabs OR extension pages (popup, options, etc.))
|
||||||
|
*/
|
||||||
|
export type ForegroundMessenger<M> = {
|
||||||
|
[K in keyof M]: MessageData<M, K> extends undefined
|
||||||
|
? (options: ForegroundMessageOptions) => Promise<MessageResponse<M, K>>
|
||||||
|
: (data: MessageData<M, K>, options: ForegroundMessageOptions) => Promise<MessageResponse<M, K>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper for chrome extension messaging with a type-safe API.
|
||||||
|
* @type To which context the messages are sent.
|
||||||
|
* @returns A proxy object that can be used to send messages to the foreground (tabs or extension pages (popup, options, etc.))
|
||||||
|
*/
|
||||||
|
export function createMessenger<M>(destination: 'foreground'): ForegroundMessenger<M>;
|
||||||
|
/**
|
||||||
|
* A wrapper for chrome extension messaging with a type-safe API.
|
||||||
|
* @param type To which context the messages are sent.
|
||||||
|
* @returns A proxy object that can be used to send messages to the background script.
|
||||||
|
*/
|
||||||
|
export function createMessenger<M>(destination: 'background'): BackgroundMessenger<M>;
|
||||||
|
/**
|
||||||
|
* A wrapper for chrome extension messaging with a type-safe API.
|
||||||
|
* @param destination To which context the messages are sent.
|
||||||
|
* @returns A proxy object that can be used to send messages to the background script.
|
||||||
|
*/
|
||||||
|
export function createMessenger<M>(destination: 'background' | 'foreground') {
|
||||||
|
let to: MessageEndpoint = MessageEndpoint.BACKGROUND;
|
||||||
|
let from: MessageEndpoint = MessageEndpoint.FOREGROUND;
|
||||||
|
|
||||||
|
if (destination === 'foreground') {
|
||||||
|
to = MessageEndpoint.FOREGROUND;
|
||||||
|
from = MessageEndpoint.BACKGROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sender = new Proxy({} as any, {
|
||||||
|
get(target, prop) {
|
||||||
|
const name = prop as keyof M;
|
||||||
|
return async (data: MessageData<M, any>, options?: ForegroundMessageOptions) => {
|
||||||
|
const message: Message<M> = {
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (to === MessageEndpoint.FOREGROUND && options) {
|
||||||
|
// for messages sent to the tabs, we want to send to the tabs using chrome.tabs.sendMessage,
|
||||||
|
const { tabId } = options;
|
||||||
|
if (typeof tabId === 'number') {
|
||||||
|
return chrome.tabs.sendMessage(tabId, message, { frameId: options.frameId });
|
||||||
|
}
|
||||||
|
if (tabId === 'ACTIVE_TAB') {
|
||||||
|
const tab = (await chrome.tabs.query({ active: true, currentWindow: true }))[0];
|
||||||
|
if (tab && tab.id) {
|
||||||
|
return chrome.tabs.sendMessage(tab.id, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tabId === 'ALL') {
|
||||||
|
const tabs = (await chrome.tabs.query({})).filter(tab => tab.id !== undefined && tab.url);
|
||||||
|
return Promise.any([
|
||||||
|
...tabs.map(tab => chrome.tabs.sendMessage(tab.id!, message)),
|
||||||
|
chrome.runtime.sendMessage(message),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chrome.runtime.sendMessage(message);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return sender;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import type { Message, MessageData } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper function to create a hook that can listen for messages coming through chrome.runtime.onMessage
|
||||||
|
* with e2e type safety
|
||||||
|
* @returns a hook that can be used to listen for messages from the background script.
|
||||||
|
*/
|
||||||
|
export function createUseMessage<M>() {
|
||||||
|
return function useMessage<N extends keyof M, D extends MessageData<M, N>>(
|
||||||
|
name: N,
|
||||||
|
callback: (data: D) => void
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (message: Message<M>) => {
|
||||||
|
if (message.name === name) {
|
||||||
|
callback(message.data);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener(onMessage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chrome.runtime.onMessage.removeListener(onMessage);
|
||||||
|
};
|
||||||
|
}, [name, callback]);
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/lib/chrome-extension-toolkit/messaging/index.ts
Normal file
3
src/lib/chrome-extension-toolkit/messaging/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './createMessenger';
|
||||||
|
export * from './createUseMessage';
|
||||||
|
export * from './MessageListener';
|
||||||
15
src/lib/chrome-extension-toolkit/modules.d.ts
vendored
Normal file
15
src/lib/chrome-extension-toolkit/modules.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
NODE_ENV: 'development' | 'production' | 'test';
|
||||||
|
CI?: string;
|
||||||
|
EXTENSION_STORAGE_PASSWORD?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Environment = typeof process.env.NODE_ENV;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this file has no import/export statements (i.e. is a script)
|
||||||
|
// convert it into a module by adding an empty export statement.
|
||||||
|
export {};
|
||||||
214
src/lib/chrome-extension-toolkit/storage/Security.ts
Normal file
214
src/lib/chrome-extension-toolkit/storage/Security.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
|
const { crypto } = globalThis;
|
||||||
|
|
||||||
|
/** the number of times the key_algo will be run on the password */
|
||||||
|
const ITERATIONS = 470_000;
|
||||||
|
/** the algorithm used to derive the password key from the password */
|
||||||
|
const KEY_ALGO = 'PBKDF2';
|
||||||
|
/** the algorithm used to derive the cipher key from the password key */
|
||||||
|
const HASH_ALGO = 'SHA-256';
|
||||||
|
/** the algorithm used to encrypt the data */
|
||||||
|
const CIPHER_MODE = 'AES-GCM';
|
||||||
|
|
||||||
|
/** the size of the cipher key */
|
||||||
|
const CIPHER_SIZE = 256;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class that provides encryption and decryption methods for use when storing data in the Stores provided by this library.
|
||||||
|
*/
|
||||||
|
export class Security {
|
||||||
|
private encoder = new TextEncoder();
|
||||||
|
private decoder = new TextDecoder();
|
||||||
|
|
||||||
|
private cachedPasswordKey?: CryptoKey;
|
||||||
|
|
||||||
|
public static MISSING_PASSWORD_ERROR_MESSAGE =
|
||||||
|
'You must set the EXTENSION_STORAGE_PASSWORD environment variable to use encrypted storage.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the password key, either from the cache or by generating a new one from the password environment variable
|
||||||
|
*/
|
||||||
|
private async getPasswordKey(): Promise<CryptoKey> {
|
||||||
|
if (this.cachedPasswordKey) return this.cachedPasswordKey;
|
||||||
|
|
||||||
|
if (!process.env.EXTENSION_STORAGE_PASSWORD) {
|
||||||
|
throw new Error(Security.MISSING_PASSWORD_ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordBuffer = this.encoder.encode(process.env.EXTENSION_STORAGE_PASSWORD);
|
||||||
|
this.cachedPasswordKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
passwordBuffer,
|
||||||
|
{ name: KEY_ALGO },
|
||||||
|
false, // Not exportable
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
return this.cachedPasswordKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a cipher key from the password key and salt
|
||||||
|
* @param salt the salt to use when deriving the cipher key
|
||||||
|
* @param passKey the password key to use when deriving the cipher key
|
||||||
|
* @param KeyUsage the key usage for the cipher key (encrypt or decrypt)
|
||||||
|
* @returns the cipher key which can be used to encrypt or decrypt data
|
||||||
|
*/
|
||||||
|
private async deriveCipherKey(salt: Uint8Array, passKey: CryptoKey, KeyUsage: KeyUsage[]): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: KEY_ALGO,
|
||||||
|
hash: HASH_ALGO,
|
||||||
|
salt,
|
||||||
|
iterations: ITERATIONS,
|
||||||
|
},
|
||||||
|
passKey,
|
||||||
|
{
|
||||||
|
name: CIPHER_MODE,
|
||||||
|
length: CIPHER_SIZE,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
KeyUsage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns a random salt buffer for use in encryption
|
||||||
|
*/
|
||||||
|
private deriveSalt(): Uint8Array {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(BoxBuffer.SALT_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns a random IV buffer for use in encryption
|
||||||
|
*/
|
||||||
|
private deriveIv(): Uint8Array {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(BoxBuffer.IV_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a value that has been encrypted using the encrypt method
|
||||||
|
* @param value the encrypted value to decrypt
|
||||||
|
* @returns the decrypted value
|
||||||
|
*/
|
||||||
|
async decrypt(value: any) {
|
||||||
|
if (!value) return value;
|
||||||
|
if (!isString(value)) return value;
|
||||||
|
|
||||||
|
const passKey = await this.getPasswordKey();
|
||||||
|
|
||||||
|
// read in the string into a boxBuffer, and separate the salt, iv and encrypted data from it
|
||||||
|
const boxBuffer = BoxBuffer.fromBase64String(value);
|
||||||
|
const salt = boxBuffer.getSalt();
|
||||||
|
const iv = boxBuffer.getIv();
|
||||||
|
const encryptedData = boxBuffer.getEncryptedData();
|
||||||
|
|
||||||
|
// generate the decryption key
|
||||||
|
const decryptionKey = await this.deriveCipherKey(salt, passKey, ['decrypt']);
|
||||||
|
|
||||||
|
// decrypt the data using the decryption key
|
||||||
|
const decryptedDataBuffer = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: CIPHER_MODE,
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
decryptionKey,
|
||||||
|
encryptedData
|
||||||
|
);
|
||||||
|
|
||||||
|
// parse the decrypted data into a JSON object and return it
|
||||||
|
return decryptedDataBuffer.byteLength === 0 ? undefined : JSON.parse(this.decoder.decode(decryptedDataBuffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a value using the password key derived from the EXTENSION_STORAGE_PASSWORD environment variable
|
||||||
|
* @param value the value to encrypt
|
||||||
|
* @returns the encrypted value as a base64 string
|
||||||
|
*/
|
||||||
|
async encrypt(value: any): Promise<string> {
|
||||||
|
let valueString = JSON.stringify(value);
|
||||||
|
|
||||||
|
const passKey = await this.getPasswordKey();
|
||||||
|
|
||||||
|
const salt = this.deriveSalt();
|
||||||
|
const iv = this.deriveIv();
|
||||||
|
|
||||||
|
const encryptionKey = await this.deriveCipherKey(salt, passKey, ['encrypt']);
|
||||||
|
|
||||||
|
const encryptedData = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: CIPHER_MODE,
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
encryptionKey,
|
||||||
|
this.encoder.encode(valueString)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// create a boxBuffer to store the salt, iv and encrypted data together in a single buffer
|
||||||
|
const bufferSize = BoxBuffer.PREFIX_SIZE + encryptedData.byteLength;
|
||||||
|
|
||||||
|
const boxBuffer = new BoxBuffer(new Uint8Array(bufferSize));
|
||||||
|
boxBuffer.setSalt(salt);
|
||||||
|
boxBuffer.setIv(iv);
|
||||||
|
boxBuffer.setEncryptedData(encryptedData);
|
||||||
|
|
||||||
|
// return the boxBuffer as a base64 string (to make it easier to store)
|
||||||
|
return boxBuffer.toBase64String();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class representation of a buffer box in memory of the form:
|
||||||
|
* [salt][iv][encrypted data]
|
||||||
|
*/
|
||||||
|
class BoxBuffer {
|
||||||
|
private buffer: Uint8Array;
|
||||||
|
static SALT_SIZE = 16;
|
||||||
|
static IV_SIZE = 32;
|
||||||
|
|
||||||
|
static get PREFIX_SIZE() {
|
||||||
|
return BoxBuffer.SALT_SIZE + BoxBuffer.IV_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(buffer: Uint8Array) {
|
||||||
|
this.buffer = buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSalt(salt: Uint8Array) {
|
||||||
|
this.buffer.set(salt, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIv(iv: Uint8Array) {
|
||||||
|
this.buffer.set(iv, BoxBuffer.SALT_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEncryptedData(encryptedData: Uint8Array) {
|
||||||
|
this.buffer.set(encryptedData, BoxBuffer.PREFIX_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSalt(): Uint8Array {
|
||||||
|
return this.buffer.slice(0, BoxBuffer.SALT_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
getIv(): Uint8Array {
|
||||||
|
return this.buffer.slice(BoxBuffer.SALT_SIZE, BoxBuffer.PREFIX_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEncryptedData(): Uint8Array {
|
||||||
|
return this.buffer.slice(BoxBuffer.PREFIX_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
toBase64String() {
|
||||||
|
return globalThis.btoa(String.fromCharCode.apply(null, this.buffer as any));
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromBase64String(base64String: string): BoxBuffer {
|
||||||
|
const rawBuffer = Uint8Array.from(globalThis.atob(base64String), c => c.charCodeAt(0));
|
||||||
|
return new BoxBuffer(rawBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isString(value: any): value is string {
|
||||||
|
return typeof value === 'string' || value instanceof String;
|
||||||
|
}
|
||||||
447
src/lib/chrome-extension-toolkit/storage/createStore.ts
Normal file
447
src/lib/chrome-extension-toolkit/storage/createStore.ts
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { Serializable } from '../types';
|
||||||
|
import { Security } from './Security';
|
||||||
|
|
||||||
|
/** A utility type that forces you to declare all the values specified in the type interface for a module. */
|
||||||
|
export type StoreDefaults<T> = {
|
||||||
|
[P in keyof Required<T>]: Pick<T, P> extends Required<Pick<T, P>> ? T[P] : T[P] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a change in data within the store.
|
||||||
|
*/
|
||||||
|
export type DataChange<T> = {
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* The old value of the data. This will be undefined if the data was just initialized.
|
||||||
|
*/
|
||||||
|
oldValue?: Serializable<T>;
|
||||||
|
/**
|
||||||
|
* The new value of the data.
|
||||||
|
*/
|
||||||
|
newValue: Serializable<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that is called when the data in the store changes.
|
||||||
|
*/
|
||||||
|
export type OnChangedFunction<T> = (changes: DataChange<T>) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A virtual wrapper around the chrome.storage API that allows you to segment and compartmentalize your data.
|
||||||
|
* The data is all stored at the top level of the storage area, so you should namespace your keys to avoid collisions.
|
||||||
|
*/
|
||||||
|
export type Store<T = {}> = {
|
||||||
|
/**
|
||||||
|
* A unique identifier for the store. This will be prepended to all keys in the store to avoid collisions.
|
||||||
|
*/
|
||||||
|
readonly storeId: string;
|
||||||
|
/**
|
||||||
|
* The options that were passed to the createStore function
|
||||||
|
*/
|
||||||
|
readonly options: StoreOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default values for the store. These were passed to the createStore function and will be used to initialize the store if the key is not already set.
|
||||||
|
*/
|
||||||
|
readonly defaults: StoreDefaults<T>;
|
||||||
|
/**
|
||||||
|
* Initializes the store by setting any keys that are not already set to their default values. This will be called automatically when you first access a getter or setter.
|
||||||
|
*/
|
||||||
|
initialize(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the value of the specified key from the store.
|
||||||
|
* @param key the key to get the value of
|
||||||
|
* @returns a promise that resolves to the value of the specified key (wrapped in a Serialized type)
|
||||||
|
*/
|
||||||
|
get<K extends keyof T>(key: K): Promise<Serializable<T[K]>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the value of the specified key in the store.
|
||||||
|
* @param key the key to set the value of
|
||||||
|
* @param value the value to set the key to
|
||||||
|
*/
|
||||||
|
set<K extends keyof T>(key: K, value: Serializable<T[K]>): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the store with the values in the object passed in.
|
||||||
|
* @param values an object containing the keys and values to set in the store
|
||||||
|
*/
|
||||||
|
set(values: Partial<Serializable<T>>): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a specific key from the store.
|
||||||
|
* @param key the key to remove from the store
|
||||||
|
*/
|
||||||
|
remove<K extends keyof T>(key: K): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a promise that resolves to the entire contents of the store.
|
||||||
|
*/
|
||||||
|
all(): Promise<Serializable<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of all the keys in the store.
|
||||||
|
*/
|
||||||
|
keys(): (keyof T & string)[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A react hook that allows you to get and set the value of the specified key in the store from a functional component.
|
||||||
|
* @param key the key to get the value of or null to get the entire store
|
||||||
|
* @returns a tuple containing the value of the specified key, and a function to set the value
|
||||||
|
*/
|
||||||
|
use<K extends keyof T | null>(
|
||||||
|
key: K
|
||||||
|
): [
|
||||||
|
K extends keyof T ? Serializable<T[K]> : Serializable<T>,
|
||||||
|
(value: K extends keyof T ? Serializable<T[K]> : Partial<Serializable<T>>) => Promise<void>,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A react hook that allows you to get and set the value of the specified key in the store from a functional component.
|
||||||
|
* @param key the key to get the value of or null to get the entire store
|
||||||
|
* @param defaultValue the default value to use if the key is not already set
|
||||||
|
*/
|
||||||
|
use<K extends keyof T | null>(
|
||||||
|
key: K,
|
||||||
|
defaultValue: K extends keyof T ? Serializable<T[K]> : Serializable<T>
|
||||||
|
): [
|
||||||
|
K extends keyof T ? Serializable<T[K]> : Serializable<T>,
|
||||||
|
(value: K extends keyof T ? Serializable<T[K]> : Partial<Serializable<T>>) => Promise<void>,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to changes in the specified key in the store, and calls the specified function when the key changes.
|
||||||
|
* @param key the key to subscribe to
|
||||||
|
* @param callback the function to call when the key changes
|
||||||
|
*/
|
||||||
|
// @ts-expect-error
|
||||||
|
subscribe<K extends keyof T>(key: K, callback: OnChangedFunction<T[K]>): (changes, area) => void;
|
||||||
|
// @ts-expect-error
|
||||||
|
subscribe<K extends keyof T>(key: K[], callback: OnChangedFunction<T[K]>): (changes, area) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a subscription that was added with the subscribe function.
|
||||||
|
* @param sub the subscription function that was added
|
||||||
|
*/
|
||||||
|
// @ts-expect-error
|
||||||
|
unsubscribe(sub: (changes, area) => void): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options that modify the behavior of the store
|
||||||
|
*/
|
||||||
|
type StoreOptions = {
|
||||||
|
/**
|
||||||
|
* Whether or not to encrypt the data before storing it, and decrypt it when retrieving it. Defaults to false.
|
||||||
|
*/
|
||||||
|
isEncrypted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const security = new Security();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that creates a virtual storage bucket within the chrome.storage API.
|
||||||
|
*
|
||||||
|
* @param defaults the default values for the store (these will be used to initialize the store if the key is not already set, and will be used as the type for the getters and setters)
|
||||||
|
* @param area the storage area to use. Defaults to 'local'
|
||||||
|
* @returns an object which contains getters/setters for the keys in the defaults object, as well as an initialize function and an onChanged functions
|
||||||
|
*/
|
||||||
|
function createStore<T>(
|
||||||
|
storeId: string,
|
||||||
|
defaults: StoreDefaults<T>,
|
||||||
|
area: 'sync' | 'local' | 'session' | 'managed',
|
||||||
|
options?: StoreOptions
|
||||||
|
): Store<T> {
|
||||||
|
const keys = Object.keys(defaults) as string[];
|
||||||
|
const actualKeys = keys.map(key => `${storeId}:${key}`);
|
||||||
|
|
||||||
|
let isEncrypted = options?.isEncrypted || false;
|
||||||
|
|
||||||
|
if (isEncrypted && !process.env.EXTENSION_STORAGE_PASSWORD) {
|
||||||
|
throw new Error(Security.MISSING_PASSWORD_ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
defaults,
|
||||||
|
storeId,
|
||||||
|
options,
|
||||||
|
} as Store<T>;
|
||||||
|
|
||||||
|
let hasInitialized = false;
|
||||||
|
store.initialize = async () => {
|
||||||
|
const data = await chrome.storage[area].get(actualKeys);
|
||||||
|
const missingKeys = actualKeys.filter(key => data[key] === undefined);
|
||||||
|
|
||||||
|
if (missingKeys.length) {
|
||||||
|
const defaultsToSet = {};
|
||||||
|
|
||||||
|
for (const key of missingKeys) {
|
||||||
|
// @ts-expect-error
|
||||||
|
const value = defaults[key.replace(`${storeId}:`, '')];
|
||||||
|
// @ts-expect-error
|
||||||
|
defaultsToSet[key] = isEncrypted ? await security.encrypt(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await chrome.storage[area].set(defaultsToSet);
|
||||||
|
}
|
||||||
|
hasInitialized = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
store.get = async (key: any) => {
|
||||||
|
if (!hasInitialized) {
|
||||||
|
await store.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualKey = `${storeId}:${key}`;
|
||||||
|
|
||||||
|
const value = (await chrome.storage[area].get(actualKey))[actualKey];
|
||||||
|
return isEncrypted ? await security.decrypt(value) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
store.set = async (key: any, value?: any) => {
|
||||||
|
if (!hasInitialized) {
|
||||||
|
await store.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the case where key is an object
|
||||||
|
if (typeof key === 'object' && value === undefined) {
|
||||||
|
const entriesToRemove: string[] = [];
|
||||||
|
const entriesToSet = {};
|
||||||
|
|
||||||
|
for (const [k, v] of Object.entries(key)) {
|
||||||
|
const actualKey = `${storeId}:${k}`;
|
||||||
|
if (v === undefined) {
|
||||||
|
// Prepare to remove this key
|
||||||
|
entriesToRemove.push(actualKey);
|
||||||
|
} else {
|
||||||
|
// @ts-expect-error
|
||||||
|
entriesToSet[actualKey] = isEncrypted ? await security.encrypt(v) : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove keys with undefined values
|
||||||
|
if (entriesToRemove.length > 0) {
|
||||||
|
await chrome.storage[area].remove(entriesToRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set keys with defined values
|
||||||
|
if (Object.keys(entriesToSet).length > 0) {
|
||||||
|
await chrome.storage[area].set(entriesToSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// now we know key is a string, so lets either set or remove it directly
|
||||||
|
|
||||||
|
const actualKey = `${storeId}:${key}`;
|
||||||
|
if (value === undefined) {
|
||||||
|
// Remove if value is explicitly undefined
|
||||||
|
return await chrome.storage[area].remove(actualKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the value, applying encryption if necessary
|
||||||
|
await chrome.storage[area].set({
|
||||||
|
[actualKey]: isEncrypted ? await security.encrypt(value) : value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
store.remove = async (key: any) => {
|
||||||
|
if (!hasInitialized) {
|
||||||
|
await store.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualKey = `${storeId}:${key}`;
|
||||||
|
await chrome.storage[area].remove(actualKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
store.all = async () => {
|
||||||
|
if (!hasInitialized) {
|
||||||
|
await store.initialize();
|
||||||
|
}
|
||||||
|
const fullStore = await chrome.storage[area].get(actualKeys);
|
||||||
|
if (isEncrypted) {
|
||||||
|
await Promise.all(
|
||||||
|
keys.map(async key => {
|
||||||
|
const actualKey = `${storeId}:${key}`;
|
||||||
|
fullStore[key] = await security.decrypt(fullStore[actualKey]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// now we need to remove the storeId from the keys
|
||||||
|
Object.keys(fullStore).forEach(actualKey => {
|
||||||
|
const newKey = actualKey.replace(`${storeId}:`, '');
|
||||||
|
fullStore[newKey] = fullStore[actualKey];
|
||||||
|
delete fullStore[actualKey];
|
||||||
|
});
|
||||||
|
return fullStore as Serializable<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
store.keys = () => keys as (keyof T & string)[];
|
||||||
|
|
||||||
|
store.subscribe = (key: string | string[], callback) => {
|
||||||
|
// @ts-expect-error
|
||||||
|
const sub = async (changes, areaName) => {
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
|
const actualKeys = keys.map(k => `${storeId}:${k}`);
|
||||||
|
|
||||||
|
if (areaName !== area) return;
|
||||||
|
|
||||||
|
// make sure that there are keys is in the changes object
|
||||||
|
const subKeys = Object.keys(changes).filter(k => actualKeys.includes(k));
|
||||||
|
if (!subKeys.length) return;
|
||||||
|
|
||||||
|
subKeys.forEach(async actualKey => {
|
||||||
|
const key = actualKey.replace(`${storeId}:`, '');
|
||||||
|
const [oldValue, newValue] = await Promise.all([
|
||||||
|
isEncrypted ? security.decrypt(changes[actualKey].oldValue) : changes[actualKey].oldValue,
|
||||||
|
isEncrypted ? security.decrypt(changes[actualKey].newValue) : changes[actualKey].newValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
callback({
|
||||||
|
key,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener(sub);
|
||||||
|
return sub;
|
||||||
|
};
|
||||||
|
|
||||||
|
store.unsubscribe = sub => {
|
||||||
|
chrome.storage.onChanged.removeListener(sub);
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
store.use = (key: keyof T | null, defaultValue?: key extends null ? T : T[typeof key]) => {
|
||||||
|
const initialValue: any = (() => {
|
||||||
|
// an explicit default value was passed, use it
|
||||||
|
if (arguments.length === 2) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
// a key was passed, but no default value was passed, use the default value from the defaults object
|
||||||
|
if (key === null) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
// no key was passed, use the default value from the defaults object
|
||||||
|
return defaults[key];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
const onChange = ({ key: k, newValue }: DataChange<T>) => {
|
||||||
|
if (key === null) {
|
||||||
|
// @ts-expect-error
|
||||||
|
setValue(prev => ({ ...prev, [k]: newValue }) as any);
|
||||||
|
} else {
|
||||||
|
setValue(newValue as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (key === null) {
|
||||||
|
store
|
||||||
|
.all()
|
||||||
|
.then(setValue)
|
||||||
|
.then(() => {
|
||||||
|
store.subscribe(store.keys(), onChange as any);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
store
|
||||||
|
.get(key)
|
||||||
|
.then(setValue)
|
||||||
|
.then(() => {
|
||||||
|
store.subscribe(key, onChange as any);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
store.unsubscribe(onChange as any);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
const set = async newValue => {
|
||||||
|
if (key === null) {
|
||||||
|
await store.set(newValue as any);
|
||||||
|
} else {
|
||||||
|
await store.set(key, newValue as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [value, set] as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that creates a virtual storage bucket within the chrome.storage.local API.
|
||||||
|
* This store will persist across browser sessions and be stored on the user's computer.
|
||||||
|
*
|
||||||
|
* @param storeId A unique key to use for this store. This will be prepended to all keys in the store to avoid collisions. ex: 'my-store' -> 'my-store:myKey'
|
||||||
|
* @param defaults the default values for the store (these will be used to initialize the store if the key is not already set, and will be used as the type for the getters and setters)
|
||||||
|
* @param computed an optional function that allows you to override the generated getters and setters with your own. Provides a reference to the store itself so you can access this store's getters and setters.
|
||||||
|
* @param area the storage area to use. Defaults to 'local'
|
||||||
|
* @returns an object which contains getters/setters for the keys in the defaults object, as well as an initialize function and an onChanged functions
|
||||||
|
*/
|
||||||
|
export function createLocalStore<T>(storeId: string, defaults: StoreDefaults<T>, options?: StoreOptions): Store<T> {
|
||||||
|
return createStore(storeId, defaults, 'local', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that creates a virtual storage bucket within the chrome.storage.sync API.
|
||||||
|
* This store will persist across browser sessions and be stored on the user's Google account (if they are logged in).
|
||||||
|
* This means that the data will be synced across all of the user's devices.
|
||||||
|
*
|
||||||
|
* @param storeId A unique key to use for this store. This will be prepended to all keys in the store to avoid collisions. ex: 'my-store' -> 'my-store:myKey'
|
||||||
|
* @param defaults the default values for the store (these will be used to initialize the store if the key is not already set, and will be used as the type for the getters and setters)
|
||||||
|
* @param options options that modify the behavior of the store
|
||||||
|
* @returns an object which contains getters/setters for the keys in the defaults object, as well as an initialize function and an onChanged functions
|
||||||
|
*/
|
||||||
|
export function createSyncStore<T>(storeId: string, defaults: StoreDefaults<T>, options?: StoreOptions): Store<T> {
|
||||||
|
return createStore(storeId, defaults, 'sync', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that creates a virtual storage bucket within the chrome.storage.managed API.
|
||||||
|
* This store will persist across browser sessions and managed by the administrator of the user's computer.
|
||||||
|
*
|
||||||
|
* @param storeId A unique key to use for this store. This will be prepended to all keys in the store to avoid collisions. ex: 'my-store' -> 'my-store:myKey'
|
||||||
|
* @param defaults the default values for the store (these will be used to initialize the store if the key is not already set, and will be used as the type for the getters and setters)
|
||||||
|
* @param options options that modify the behavior of the store
|
||||||
|
* @returns an object which contains getters/setters for the keys in the defaults object, as well as an initialize function and an onChanged functions
|
||||||
|
* @see https://developer.chrome.com/docs/extensions/reference/storage/#type-ManagedStorageArea
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function createManagedStore<T>(storeId: string, defaults: StoreDefaults<T>, options?: StoreOptions): Store<T> {
|
||||||
|
return createStore(storeId, defaults, 'managed', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that creates a virtual storage bucket within the chrome.storage.session API.
|
||||||
|
* This store will NOT persist across browser sessions and will be stored in memory. This will reset when the browser is closed.
|
||||||
|
*
|
||||||
|
* @param storeId A unique key to use for this store. This will be prepended to all keys in the store to avoid collisions. ex: 'my-store' -> 'my-store:myKey'
|
||||||
|
* @param defaults the default values for the store (these will be used to initialize the store if the key is not already set, and will be used as the type for the getters and setters)
|
||||||
|
* @param options options that modify the behavior of the store
|
||||||
|
* @returns an object which contains getters/setters for the keys in the defaults object, as well as an initialize function and an onChanged functions
|
||||||
|
*/
|
||||||
|
export function createSessionStore<T>(storeId: string, defaults: StoreDefaults<T>, options?: StoreOptions): Store<T> {
|
||||||
|
return createStore(storeId, defaults, 'session', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// interface MyStore {
|
||||||
|
// name: string;
|
||||||
|
// age: number;
|
||||||
|
// isCool?: boolean;
|
||||||
|
// }
|
||||||
|
// const store = createLocalStore<MyStore>('my-store', {
|
||||||
|
// age: 0,
|
||||||
|
// isCool: false,
|
||||||
|
// name: 'John Doe',
|
||||||
|
// });
|
||||||
2
src/lib/chrome-extension-toolkit/storage/index.ts
Normal file
2
src/lib/chrome-extension-toolkit/storage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './createStore';
|
||||||
|
export * from './Security';
|
||||||
70
src/lib/chrome-extension-toolkit/types/Messaging.ts
Normal file
70
src/lib/chrome-extension-toolkit/types/Messaging.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Serializable } from './Serialization';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MessageDefinition is a record of message names and their data types.
|
||||||
|
* The data type is the type of the first argument of the message handler.
|
||||||
|
* The return type of the message handler is the resolved type of the promise returned by the message sender.
|
||||||
|
*/
|
||||||
|
export type MessageDefinition<T extends Record<string, (data: Record<string, any> | undefined) => any>> = T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper type to extract the data type of a message handler.
|
||||||
|
*/
|
||||||
|
export type MessageData<M, K extends keyof M> = Serializable<
|
||||||
|
Parameters<M[K] extends (...args: any) => any ? M[K] : never>[0]
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper type to extract the resolved type of a message handler.
|
||||||
|
*/
|
||||||
|
export type MessageResponse<M, K extends keyof M> = Serializable<
|
||||||
|
ReturnType<M[K] extends (...args: any) => any ? M[K] : never>
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* The internal object representing a message sent between chrome extension contexts.
|
||||||
|
*/
|
||||||
|
export type Message<M> = {
|
||||||
|
name: keyof M;
|
||||||
|
data: MessageData<M, keyof M>;
|
||||||
|
from: MessageEndpoint;
|
||||||
|
to: MessageEndpoint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the extension context in which a message is either sent or received.
|
||||||
|
* The context is either the background script/service worker or a tab.
|
||||||
|
*/
|
||||||
|
export enum MessageEndpoint {
|
||||||
|
/** The background script or service worker. */
|
||||||
|
BACKGROUND = 'BACKGROUND',
|
||||||
|
/** A foreground or extension page (popup, options, etc) */
|
||||||
|
FOREGROUND = 'FOREGROUND',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that implements the message handlers for each of the messages in the message definition.
|
||||||
|
*/
|
||||||
|
export type MessageHandler<M> = {
|
||||||
|
[K in keyof M]: (context: {
|
||||||
|
/** The data sent with the message. */
|
||||||
|
data: MessageData<M, K>;
|
||||||
|
/** The tab or page or background service worker that sent the message. */
|
||||||
|
sender: chrome.runtime.MessageSender;
|
||||||
|
/** A function that can be used to send a response asynchronously to the sender. */
|
||||||
|
sendResponse: (response: MessageResponse<M, K>) => void;
|
||||||
|
}) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that can be used to handle messages coming from another extension context.
|
||||||
|
*/
|
||||||
|
export interface IMessageListener<M> {
|
||||||
|
/**
|
||||||
|
* Starts listening for messages. When a message is received, the corresponding message handler is called.
|
||||||
|
*/
|
||||||
|
listen: (handler: MessageHandler<M>) => void;
|
||||||
|
/**
|
||||||
|
* Stops listening for messages.
|
||||||
|
*/
|
||||||
|
unlisten: () => void;
|
||||||
|
}
|
||||||
69
src/lib/chrome-extension-toolkit/types/Serialization.ts
Normal file
69
src/lib/chrome-extension-toolkit/types/Serialization.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
type Primitive = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type that represents a serialized object. This is a recursive type that will serialize all properties of an object, except for functions which are ignored (and thus not serialized)
|
||||||
|
*/
|
||||||
|
export type Serialized<T> = {
|
||||||
|
[K in keyof T as T[K] extends Function ? never : K]: Serializable<T[K]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a type that can be serialized to JSON. This could either be a primitive type, an array of serializable types, or a complex object
|
||||||
|
* Recursively serializes an object to a JSON-compatible object.
|
||||||
|
*/
|
||||||
|
export type Serializable<T> = T extends Primitive
|
||||||
|
? T
|
||||||
|
: T extends Array<infer U>
|
||||||
|
? Array<Serializable<U>>
|
||||||
|
: T extends object
|
||||||
|
? Serialized<T>
|
||||||
|
: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes a value to a JSON-compatible object (i.e. a Serializable<T>)
|
||||||
|
* @returns A JSON-compatible object that represents the value passed in with all functions removed and all objects recursively serialized
|
||||||
|
*/
|
||||||
|
export function serialize<T>(value: T): Serializable<T> {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as Serializable<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// THIS IS FOR TESTINWG THE TYPING
|
||||||
|
|
||||||
|
// type Test2 = {
|
||||||
|
// test: string;
|
||||||
|
// openNewTab: (url: string) => void;
|
||||||
|
// url?: URL;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// type Test1 = {
|
||||||
|
// openNewTab: (url: string) => void;
|
||||||
|
// count: number;
|
||||||
|
// url: URL;
|
||||||
|
// urls?: URL[];
|
||||||
|
// foo: Test2;
|
||||||
|
// bar?: Test2;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// let x: Serialized<Test1>;
|
||||||
|
// // ^?
|
||||||
|
|
||||||
|
// type SerializedData = Serializable<Data>;
|
||||||
|
|
||||||
|
// let data: SerializedData = {} as any;
|
||||||
|
|
||||||
|
// data.url = undefined;
|
||||||
|
|
||||||
|
// data = {
|
||||||
|
// a: 'test',
|
||||||
|
// b: 1,
|
||||||
|
// c: true,
|
||||||
|
// d: {
|
||||||
|
// e: 'test',
|
||||||
|
// t: undefined,
|
||||||
|
// },
|
||||||
|
// urls: [],
|
||||||
|
// };
|
||||||
|
|
||||||
|
// data.d = {
|
||||||
|
// e: 'test',
|
||||||
|
// };
|
||||||
47
src/lib/chrome-extension-toolkit/types/Test.ts
Normal file
47
src/lib/chrome-extension-toolkit/types/Test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
type Primitive = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
|
type Serialized<T> = {
|
||||||
|
[K in keyof T as T[K] extends Function ? never : K]: Serializable<T[K]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Serializable<T> = T extends Primitive
|
||||||
|
? T
|
||||||
|
: T extends Array<infer U>
|
||||||
|
? Array<Serializable<U>>
|
||||||
|
: T extends object
|
||||||
|
? Serialized<T>
|
||||||
|
: T;
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
a: string;
|
||||||
|
b: number;
|
||||||
|
c: boolean;
|
||||||
|
url?: URL;
|
||||||
|
d: {
|
||||||
|
e: string;
|
||||||
|
t?: URL;
|
||||||
|
};
|
||||||
|
get: () => void;
|
||||||
|
urls: URL[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SerializedData = Serializable<Data>;
|
||||||
|
|
||||||
|
let x: SerializedData = {} as any;
|
||||||
|
|
||||||
|
x.url = undefined;
|
||||||
|
|
||||||
|
x = {
|
||||||
|
a: 'test',
|
||||||
|
b: 1,
|
||||||
|
c: true,
|
||||||
|
d: {
|
||||||
|
e: 'test',
|
||||||
|
t: undefined,
|
||||||
|
},
|
||||||
|
urls: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
x.d = {
|
||||||
|
e: 'test',
|
||||||
|
};
|
||||||
6
src/lib/chrome-extension-toolkit/types/index.ts
Normal file
6
src/lib/chrome-extension-toolkit/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './Messaging';
|
||||||
|
export * from './Serialization';
|
||||||
|
|
||||||
|
export type AddParameters<TFunction extends (...args: any) => any, TParameters extends [...args: any]> = (
|
||||||
|
...args: [...Parameters<TFunction>, ...TParameters]
|
||||||
|
) => ReturnType<TFunction>;
|
||||||
48
src/lib/chrome-extension-toolkit/utils/Console.ts
Normal file
48
src/lib/chrome-extension-toolkit/utils/Console.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const LOG_BACKGROUND_COLOR = '#2196F3';
|
||||||
|
const LOG_TEXT_COLOR = '#fff';
|
||||||
|
|
||||||
|
const SUCCESS_BACKGROUND_COLOR = '#4CAF50';
|
||||||
|
const SUCCESS_TEXT_COLOR = '#fff';
|
||||||
|
|
||||||
|
const ERROR_BACKGROUND_COLOR = '#F44336';
|
||||||
|
const ERROR_TEXT_COLOR = '#fff';
|
||||||
|
|
||||||
|
const WARNING_BACKGROUND_COLOR = '#FFC107';
|
||||||
|
const WARNING_TEXT_COLOR = '#fff';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for logging messages with different styles.
|
||||||
|
*/
|
||||||
|
export class Console {
|
||||||
|
static log(...args: any[]) {
|
||||||
|
console.log(
|
||||||
|
`%c ${args[0]} `,
|
||||||
|
`background: ${LOG_BACKGROUND_COLOR}; color: ${LOG_TEXT_COLOR}`,
|
||||||
|
...args.slice(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static success(...args: any[]) {
|
||||||
|
console.log(
|
||||||
|
`%c ${args[0]} `,
|
||||||
|
`background: ${SUCCESS_BACKGROUND_COLOR}; color: ${SUCCESS_TEXT_COLOR}`,
|
||||||
|
...args.slice(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static error(...args: any[]) {
|
||||||
|
console.error(
|
||||||
|
`%c ${args[0]} `,
|
||||||
|
`background: ${ERROR_BACKGROUND_COLOR}; color: ${ERROR_TEXT_COLOR}`,
|
||||||
|
...args.slice(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static warn(...args: any[]) {
|
||||||
|
console.warn(
|
||||||
|
`%c ${args[0]} `,
|
||||||
|
`background: ${WARNING_BACKGROUND_COLOR}; color: ${WARNING_TEXT_COLOR}`,
|
||||||
|
...args.slice(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/lib/chrome-extension-toolkit/utils/string.ts
Normal file
8
src/lib/chrome-extension-toolkit/utils/string.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Capitalizes the first letter of a string
|
||||||
|
* @param str the string to capitalize
|
||||||
|
* @returns the capitalized string
|
||||||
|
*/
|
||||||
|
export function capitalize(str: string): string {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
@@ -271,12 +271,12 @@ export default function Page404(): JSX.Element {
|
|||||||
}
|
}
|
||||||
function _0x5629d1() {
|
function _0x5629d1() {
|
||||||
let _0x13c635 = _0xdd3699;
|
let _0x13c635 = _0xdd3699;
|
||||||
_0x5b7f43(),
|
(_0x5b7f43(),
|
||||||
_0x16f39e[_0x13c635(0x81)](
|
_0x16f39e[_0x13c635(0x81)](
|
||||||
_0x228047,
|
_0x228047,
|
||||||
0x9c + -0x1 * 0x23ab + 0x230f * 0x1,
|
0x9c + -0x1 * 0x23ab + 0x230f * 0x1,
|
||||||
-0x1c26 + 0x7bf + -0x6cd * -0x3
|
-0x1c26 + 0x7bf + -0x6cd * -0x3
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
let _0x93f360 = 0x64 * 0x49 + 0x1e1e + -0x3aa2;
|
let _0x93f360 = 0x64 * 0x49 + 0x1e1e + -0x3aa2;
|
||||||
function _0x5b7f43() {
|
function _0x5b7f43() {
|
||||||
@@ -373,12 +373,12 @@ export default function Page404(): JSX.Element {
|
|||||||
(-0x2469 + 0x156a * -0x1 + 0x39e2)) +
|
(-0x2469 + 0x156a * -0x1 + 0x39e2)) +
|
||||||
(-0x1 * 0x13f8 + 0x6df + -0x1 * -0xd29);
|
(-0x1 * 0x13f8 + 0x6df + -0x1 * -0xd29);
|
||||||
if (_0x3e178a == 0x1 * -0x1a87 + 0x1fdd + -0x555 * 0x1) {
|
if (_0x3e178a == 0x1 * -0x1a87 + 0x1fdd + -0x555 * 0x1) {
|
||||||
(_0x546fb5 =
|
((_0x546fb5 =
|
||||||
(_0x227002 * (-0x10d7 + 0x1 * 0x15ad + -0x2 * 0x263)) &
|
(_0x227002 * (-0x10d7 + 0x1 * 0x15ad + -0x2 * 0x263)) &
|
||||||
(-0x25ca * 0x1 + 0x8 * 0x278 + -0x1 * -0x1219)),
|
(-0x25ca * 0x1 + 0x8 * 0x278 + -0x1 * -0x1219)),
|
||||||
(_0x1667c5 =
|
(_0x1667c5 =
|
||||||
(_0x32116b * (0x4d3 + 0x1c09 * -0x1 + 0x3 * 0x7c2)) &
|
(_0x32116b * (0x4d3 + 0x1c09 * -0x1 + 0x3 * 0x7c2)) &
|
||||||
(-0xf06 * 0x2 + -0x144f * -0x1 + -0x344 * -0x3));
|
(-0xf06 * 0x2 + -0x144f * -0x1 + -0x344 * -0x3)));
|
||||||
if (_0x5b3085 < -0xa * 0xed + -0xd19 + 0x1 * 0x165b)
|
if (_0x5b3085 < -0xa * 0xed + -0xd19 + 0x1 * 0x165b)
|
||||||
_0x1667c5 += -0xd48 + 0xf6c + 0xc * -0x2b;
|
_0x1667c5 += -0xd48 + 0xf6c + 0xc * -0x2b;
|
||||||
}
|
}
|
||||||
@@ -410,10 +410,10 @@ export default function Page404(): JSX.Element {
|
|||||||
(-0x2709 + -0x6 * -0x312 + -0x39a * -0x6)),
|
(-0x2709 + -0x6 * -0x312 + -0x39a * -0x6)),
|
||||||
(_0x267dd3 = _0x38c463));
|
(_0x267dd3 = _0x38c463));
|
||||||
}
|
}
|
||||||
(_0x227002 += _0x4b089b),
|
((_0x227002 += _0x4b089b),
|
||||||
(_0x2aec99 += _0x5b3085),
|
(_0x2aec99 += _0x5b3085),
|
||||||
(_0x32116b += _0x1eaaad),
|
(_0x32116b += _0x1eaaad),
|
||||||
(_0x38c463 += _0x57383c);
|
(_0x38c463 += _0x57383c));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _0x5cba48 =
|
let _0x5cba48 =
|
||||||
@@ -430,7 +430,7 @@ export default function Page404(): JSX.Element {
|
|||||||
let _0xdf8389 =
|
let _0xdf8389 =
|
||||||
((_0x13f1b0 & (-0x247a + -0x4 * -0x9c2 + -0x39 * 0x7)) * _0x2062a9 * _0x5c387a) /
|
((_0x13f1b0 & (-0x247a + -0x4 * -0x9c2 + -0x39 * 0x7)) * _0x2062a9 * _0x5c387a) /
|
||||||
((0x1d5 * 0xa + -0x250a + -0x31 * -0x67) * (-0x7 * 0x25f + -0xae7 + 0x1c7f * 0x1));
|
((0x1d5 * 0xa + -0x250a + -0x31 * -0x67) * (-0x7 * 0x25f + -0xae7 + 0x1c7f * 0x1));
|
||||||
(_0x228047[_0x4626de(0x8e)][
|
((_0x228047[_0x4626de(0x8e)][
|
||||||
(_0x132623 + _0x1a573d * _0x124180) * (0x29 * -0xa9 + -0x94 * -0x2b + -0x239 * -0x1) +
|
(_0x132623 + _0x1a573d * _0x124180) * (0x29 * -0xa9 + -0x94 * -0x2b + -0x239 * -0x1) +
|
||||||
(0x55d * 0x2 + 0xeed * 0x1 + -0xc7 * 0x21)
|
(0x55d * 0x2 + 0xeed * 0x1 + -0xc7 * 0x21)
|
||||||
] = _0x5cba48),
|
] = _0x5cba48),
|
||||||
@@ -441,7 +441,7 @@ export default function Page404(): JSX.Element {
|
|||||||
(_0x228047[_0x4626de(0x8e)][
|
(_0x228047[_0x4626de(0x8e)][
|
||||||
(_0x132623 + _0x1a573d * _0x124180) * (0x1e2a + -0x21df + -0x1 * -0x3b9) +
|
(_0x132623 + _0x1a573d * _0x124180) * (0x1e2a + -0x21df + -0x1 * -0x3b9) +
|
||||||
(0x1e79 + 0x860 * -0x2 + 0x1 * -0xdb7)
|
(0x1e79 + 0x860 * -0x2 + 0x1 * -0xdb7)
|
||||||
] = _0xdf8389);
|
] = _0xdf8389));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { MessageListener } from '@chrome-extension-toolkit';
|
||||||
import type { BACKGROUND_MESSAGES } from '@shared/messages';
|
import type { BACKGROUND_MESSAGES } from '@shared/messages';
|
||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
import updateBadgeText from '@shared/util/updateBadgeText';
|
import updateBadgeText from '@shared/util/updateBadgeText';
|
||||||
import { MessageListener } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
import onInstall from './events/onInstall';
|
import onInstall from './events/onInstall';
|
||||||
import onServiceWorkerAlive from './events/onServiceWorkerAlive';
|
import onServiceWorkerAlive from './events/onServiceWorkerAlive';
|
||||||
@@ -9,6 +9,7 @@ import onUpdate from './events/onUpdate';
|
|||||||
import browserActionHandler from './handler/browserActionHandler';
|
import browserActionHandler from './handler/browserActionHandler';
|
||||||
import calendarBackgroundHandler from './handler/calendarBackgroundHandler';
|
import calendarBackgroundHandler from './handler/calendarBackgroundHandler';
|
||||||
import CESHandler from './handler/CESHandler';
|
import CESHandler from './handler/CESHandler';
|
||||||
|
import gitHubStatsHandler from './handler/gitHubStatsHandler';
|
||||||
import tabManagementHandler from './handler/tabManagementHandler';
|
import tabManagementHandler from './handler/tabManagementHandler';
|
||||||
import userScheduleHandler from './handler/userScheduleHandler';
|
import userScheduleHandler from './handler/userScheduleHandler';
|
||||||
|
|
||||||
@@ -52,17 +53,18 @@ const messageListener = new MessageListener<BACKGROUND_MESSAGES>({
|
|||||||
...userScheduleHandler,
|
...userScheduleHandler,
|
||||||
...CESHandler,
|
...CESHandler,
|
||||||
...calendarBackgroundHandler,
|
...calendarBackgroundHandler,
|
||||||
|
...gitHubStatsHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
messageListener.listen();
|
messageListener.listen();
|
||||||
|
|
||||||
UserScheduleStore.listen('schedules', async schedules => {
|
UserScheduleStore.subscribe('schedules', async schedules => {
|
||||||
const index = await UserScheduleStore.get('activeIndex');
|
const index = await UserScheduleStore.get('activeIndex');
|
||||||
const numCourses = schedules.newValue[index]?.courses?.length;
|
const numCourses = schedules.newValue[index]?.courses?.length;
|
||||||
updateBadgeText(numCourses || 0);
|
updateBadgeText(numCourses || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
UserScheduleStore.listen('activeIndex', async ({ newValue }) => {
|
UserScheduleStore.subscribe('activeIndex', async ({ newValue }) => {
|
||||||
const schedules = await UserScheduleStore.get('schedules');
|
const schedules = await UserScheduleStore.get('schedules');
|
||||||
const numCourses = schedules[newValue]?.courses?.length;
|
const numCourses = schedules[newValue]?.courses?.length;
|
||||||
updateBadgeText(numCourses || 0);
|
updateBadgeText(numCourses || 0);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import openNewTab from '@background/util/openNewTab';
|
import openNewTab from '@background/util/openNewTab';
|
||||||
|
import type { MessageHandler } from '@chrome-extension-toolkit';
|
||||||
import type CESMessage from '@shared/messages/CESMessage';
|
import type CESMessage from '@shared/messages/CESMessage';
|
||||||
import type { MessageHandler } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
const CESFall2023Url = 'https://utexas.bluera.com/utexas/rpvl.aspx?rid=d3db767b-049f-46c5-9a67-29c21c29c580®l=en-US';
|
const CESFall2023Url = 'https://utexas.bluera.com/utexas/rpvl.aspx?rid=d3db767b-049f-46c5-9a67-29c21c29c580®l=en-US';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { MessageHandler } from '@chrome-extension-toolkit';
|
||||||
import type BrowserActionMessages from '@shared/messages/BrowserActionMessages';
|
import type BrowserActionMessages from '@shared/messages/BrowserActionMessages';
|
||||||
import type { MessageHandler } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
const browserActionHandler: MessageHandler<BrowserActionMessages> = {
|
const browserActionHandler: MessageHandler<BrowserActionMessages> = {
|
||||||
disableBrowserAction({ sender, sendResponse }) {
|
disableBrowserAction({ sender, sendResponse }) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { TabWithId } from '@background/util/openNewTab';
|
import type { TabWithId } from '@background/util/openNewTab';
|
||||||
import openNewTab from '@background/util/openNewTab';
|
import openNewTab from '@background/util/openNewTab';
|
||||||
|
import type { MessageHandler } from '@chrome-extension-toolkit';
|
||||||
import { tabs } from '@shared/messages';
|
import { tabs } from '@shared/messages';
|
||||||
import type { CalendarBackgroundMessages } from '@shared/messages/CalendarMessages';
|
import type { CalendarBackgroundMessages } from '@shared/messages/CalendarMessages';
|
||||||
import { OptionsStore } from '@shared/storage/OptionsStore';
|
import { OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
import { CRX_PAGES } from '@shared/types/CRXPages';
|
import { CRX_PAGES } from '@shared/types/CRXPages';
|
||||||
import type { MessageHandler } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
const getAllTabInfos = async () => {
|
const getAllTabInfos = async () => {
|
||||||
const openTabs = (await chrome.tabs.query({})).filter((tab): tab is TabWithId => tab.id !== undefined);
|
const openTabs = (await chrome.tabs.query({})).filter((tab): tab is TabWithId => tab.id !== undefined);
|
||||||
const results = await Promise.allSettled(openTabs.map(tab => tabs.getTabInfo(undefined, tab.id)));
|
const results = await Promise.allSettled(openTabs.map(tab => tabs.getTabInfo({ tabId: tab.id })));
|
||||||
|
|
||||||
type TabInfo = PromiseFulfilledResult<Awaited<ReturnType<typeof tabs.getTabInfo>>>;
|
type TabInfo = PromiseFulfilledResult<Awaited<ReturnType<typeof tabs.getTabInfo>>>;
|
||||||
return results
|
return results
|
||||||
@@ -34,7 +34,7 @@ const calendarBackgroundHandler: MessageHandler<CalendarBackgroundMessages> = {
|
|||||||
|
|
||||||
await chrome.tabs.update(tabid, { active: true });
|
await chrome.tabs.update(tabid, { active: true });
|
||||||
await chrome.windows.update(openCalendarTabInfo.tab.windowId, { focused: true, drawAttention: true });
|
await chrome.windows.update(openCalendarTabInfo.tab.windowId, { focused: true, drawAttention: true });
|
||||||
if (uniqueId !== undefined) await tabs.openCoursePopup({ uniqueId }, tabid);
|
if (uniqueId !== undefined) await tabs.openCoursePopup({ uniqueId }, { tabId: tabid });
|
||||||
|
|
||||||
sendResponse(openCalendarTabInfo.tab);
|
sendResponse(openCalendarTabInfo.tab);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
31
src/pages/background/handler/gitHubStatsHandler.ts
Normal file
31
src/pages/background/handler/gitHubStatsHandler.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { MessageHandler } from '@chrome-extension-toolkit';
|
||||||
|
import type GitHubStatsMessages from '@shared/messages/GitHubStatsMessages';
|
||||||
|
import { GitHubStatsService } from '@views/lib/getGitHubStats';
|
||||||
|
|
||||||
|
const gitHubStatsService = new GitHubStatsService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for GitHub stats related messages
|
||||||
|
*/
|
||||||
|
const gitHubStatsHandler: MessageHandler<GitHubStatsMessages> = {
|
||||||
|
async fetchGitHubStats({ data, sendResponse }) {
|
||||||
|
try {
|
||||||
|
const includeMergedPRs = data ?? false;
|
||||||
|
const stats = await gitHubStatsService.fetchGitHubStats({ includeMergedPRs });
|
||||||
|
sendResponse(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching GitHub stats in background:', error);
|
||||||
|
sendResponse({
|
||||||
|
adminGitHubStats: {},
|
||||||
|
userGitHubStats: {},
|
||||||
|
contributors: [],
|
||||||
|
names: {},
|
||||||
|
dataFetched: new Date(),
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isCached: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default gitHubStatsHandler;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { MessageHandler } from '@chrome-extension-toolkit';
|
||||||
import openNewTab from '@pages/background/util/openNewTab';
|
import openNewTab from '@pages/background/util/openNewTab';
|
||||||
import type TabManagementMessages from '@shared/messages/TabManagementMessages';
|
import type TabManagementMessages from '@shared/messages/TabManagementMessages';
|
||||||
import type { MessageHandler } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
const tabManagementHandler: MessageHandler<TabManagementMessages> = {
|
const tabManagementHandler: MessageHandler<TabManagementMessages> = {
|
||||||
getTabId({ sendResponse, sender }) {
|
getTabId({ sendResponse, sender }) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { MessageHandler } from '@chrome-extension-toolkit';
|
||||||
import addCourse from '@pages/background/lib/addCourse';
|
import addCourse from '@pages/background/lib/addCourse';
|
||||||
import clearCourses from '@pages/background/lib/clearCourses';
|
import clearCourses from '@pages/background/lib/clearCourses';
|
||||||
import createSchedule from '@pages/background/lib/createSchedule';
|
import createSchedule from '@pages/background/lib/createSchedule';
|
||||||
@@ -9,7 +10,6 @@ import switchSchedule from '@pages/background/lib/switchSchedule';
|
|||||||
import type { UserScheduleMessages } from '@shared/messages/UserScheduleMessages';
|
import type { UserScheduleMessages } from '@shared/messages/UserScheduleMessages';
|
||||||
import { Course } from '@shared/types/Course';
|
import { Course } from '@shared/types/Course';
|
||||||
import { validateLoginStatus } from '@shared/util/checkLoginStatus';
|
import { validateLoginStatus } from '@shared/util/checkLoginStatus';
|
||||||
import type { MessageHandler } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
const userScheduleHandler: MessageHandler<UserScheduleMessages> = {
|
const userScheduleHandler: MessageHandler<UserScheduleMessages> = {
|
||||||
addCourse({ data, sendResponse }) {
|
addCourse({ data, sendResponse }) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import { generateRandomId } from '@shared/util/random';
|
import { generateRandomId } from '@shared/util/random';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new schedule with the given name
|
* Creates a new schedule with the given name
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
import { Course } from '@shared/types/Course';
|
import { Course } from '@shared/types/Course';
|
||||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
import addCourse from './addCourse';
|
import addCourse from './addCourse';
|
||||||
import createSchedule from './createSchedule';
|
import createSchedule from './createSchedule';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MessageListener } from '@chrome-extension-toolkit';
|
||||||
import type TabInfoMessages from '@shared/messages/TabInfoMessages';
|
import type TabInfoMessages from '@shared/messages/TabInfoMessages';
|
||||||
import Calendar from '@views/components/calendar/Calendar';
|
import Calendar from '@views/components/calendar/Calendar';
|
||||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||||
@@ -5,7 +6,6 @@ import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot'
|
|||||||
import { MigrationDialog } from '@views/components/common/MigrationDialog';
|
import { MigrationDialog } from '@views/components/common/MigrationDialog';
|
||||||
import { WhatsNewDialog } from '@views/components/common/WhatsNewPopup';
|
import { WhatsNewDialog } from '@views/components/common/WhatsNewPopup';
|
||||||
import SentryProvider from '@views/contexts/SentryContext';
|
import SentryProvider from '@views/contexts/SentryContext';
|
||||||
import { MessageListener } from 'chrome-extension-toolkit';
|
|
||||||
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
import useKC_DABR_WASM from 'kc-dabr-wasm';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
|||||||
12
src/shared/messages/GitHubStatsMessages.ts
Normal file
12
src/shared/messages/GitHubStatsMessages.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { GitHubStatsResult } from '@shared/types/GitHubStats';
|
||||||
|
|
||||||
|
/* eslint-disable jsdoc/require-jsdoc */
|
||||||
|
|
||||||
|
export default interface GitHubStatsMessages {
|
||||||
|
/**
|
||||||
|
* Fetch GitHub statistics for all contributors
|
||||||
|
* @param includeMergedPRs - Whether to include merged PR counts (optional, default: false)
|
||||||
|
* @returns GitHub stats including commits, lines added/deleted, and optionally merged PRs
|
||||||
|
*/
|
||||||
|
fetchGitHubStats: (includeMergedPRs?: boolean) => GitHubStatsResult;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createMessenger } from 'chrome-extension-toolkit';
|
import { createMessenger } from '@chrome-extension-toolkit';
|
||||||
|
|
||||||
import type BrowserActionMessages from './BrowserActionMessages';
|
import type BrowserActionMessages from './BrowserActionMessages';
|
||||||
import type { CalendarBackgroundMessages, CalendarTabMessages } from './CalendarMessages';
|
import type { CalendarBackgroundMessages, CalendarTabMessages } from './CalendarMessages';
|
||||||
import type CESMessage from './CESMessage';
|
import type CESMessage from './CESMessage';
|
||||||
|
import type GitHubStatsMessages from './GitHubStatsMessages';
|
||||||
import type TabInfoMessages from './TabInfoMessages';
|
import type TabInfoMessages from './TabInfoMessages';
|
||||||
import type TabManagementMessages from './TabManagementMessages';
|
import type TabManagementMessages from './TabManagementMessages';
|
||||||
import type { UserScheduleMessages } from './UserScheduleMessages';
|
import type { UserScheduleMessages } from './UserScheduleMessages';
|
||||||
@@ -14,7 +15,8 @@ export type BACKGROUND_MESSAGES = BrowserActionMessages &
|
|||||||
TabManagementMessages &
|
TabManagementMessages &
|
||||||
UserScheduleMessages &
|
UserScheduleMessages &
|
||||||
CESMessage &
|
CESMessage &
|
||||||
CalendarBackgroundMessages;
|
CalendarBackgroundMessages &
|
||||||
|
GitHubStatsMessages;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is a type with all the message definitions that can be sent TO specific tabs
|
* This is a type with all the message definitions that can be sent TO specific tabs
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { createLocalStore } from '@chrome-extension-toolkit';
|
||||||
import type { CachedData } from '@shared/types/CachedData';
|
import type { CachedData } from '@shared/types/CachedData';
|
||||||
import { createLocalStore, debugStore } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
interface ICacheStore {
|
interface ICacheStore {
|
||||||
github: Record<string, CachedData<unknown>>;
|
github: Record<string, CachedData<unknown>>;
|
||||||
@@ -8,8 +8,8 @@ interface ICacheStore {
|
|||||||
/**
|
/**
|
||||||
* A store that is used for storing cached data such as GitHub contributors
|
* A store that is used for storing cached data such as GitHub contributors
|
||||||
*/
|
*/
|
||||||
export const CacheStore = createLocalStore<ICacheStore>({
|
export const CacheStore = createLocalStore<ICacheStore>('CacheStore', {
|
||||||
github: {},
|
github: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
debugStore({ cacheStore: CacheStore });
|
// debugStore({ cacheStore: CacheStore });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createLocalStore, debugStore } from 'chrome-extension-toolkit';
|
import { createLocalStore } from '@chrome-extension-toolkit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A store that is used to store data that is only relevant during development
|
* A store that is used to store data that is only relevant during development
|
||||||
@@ -18,7 +18,7 @@ interface IDevStore {
|
|||||||
reloadTabId?: number;
|
reloadTabId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DevStore = createLocalStore<IDevStore>({
|
export const DevStore = createLocalStore<IDevStore>('DevStore', {
|
||||||
isDeveloper: false,
|
isDeveloper: false,
|
||||||
debugTabId: undefined,
|
debugTabId: undefined,
|
||||||
isTabReloading: true,
|
isTabReloading: true,
|
||||||
@@ -27,4 +27,4 @@ export const DevStore = createLocalStore<IDevStore>({
|
|||||||
reloadTabId: undefined,
|
reloadTabId: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
debugStore({ devStore: DevStore });
|
// debugStore({ devStore: DevStore });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createLocalStore, debugStore } from 'chrome-extension-toolkit';
|
import { createLocalStore } from '@chrome-extension-toolkit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A store that is used for storing user options
|
* A store that is used for storing user options
|
||||||
@@ -12,10 +12,10 @@ interface IExtensionStore {
|
|||||||
lastWhatsNewPopupVersion: number;
|
lastWhatsNewPopupVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExtensionStore = createLocalStore<IExtensionStore>({
|
export const ExtensionStore = createLocalStore<IExtensionStore>('ExtensionStore', {
|
||||||
version: chrome.runtime.getManifest().version,
|
version: chrome.runtime.getManifest().version,
|
||||||
lastUpdate: Date.now(),
|
lastUpdate: Date.now(),
|
||||||
lastWhatsNewPopupVersion: 0,
|
lastWhatsNewPopupVersion: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
debugStore({ ExtensionStore });
|
// debugStore({ ExtensionStore });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSyncStore, debugStore } from 'chrome-extension-toolkit';
|
import { createSyncStore } from '@chrome-extension-toolkit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A store that is used for storing user options
|
* A store that is used for storing user options
|
||||||
@@ -28,7 +28,7 @@ export interface IOptionsStore {
|
|||||||
allowMoreSchedules: boolean;
|
allowMoreSchedules: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionsStore = createSyncStore<IOptionsStore>({
|
export const OptionsStore = createSyncStore<IOptionsStore>('OptionsStore', {
|
||||||
enableCourseStatusChips: false,
|
enableCourseStatusChips: false,
|
||||||
enableHighlightConflicts: true,
|
enableHighlightConflicts: true,
|
||||||
enableScrollToLoad: true,
|
enableScrollToLoad: true,
|
||||||
@@ -58,4 +58,4 @@ export const initSettings = async () =>
|
|||||||
|
|
||||||
// Clothing retailer right
|
// Clothing retailer right
|
||||||
|
|
||||||
debugStore({ OptionsStore });
|
// debugStore({ OptionsStore });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { createLocalStore } from '@chrome-extension-toolkit';
|
||||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import { createLocalStore, debugStore } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
import { generateRandomId } from '../util/random';
|
import { generateRandomId } from '../util/random';
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ interface IUserScheduleStore {
|
|||||||
/**
|
/**
|
||||||
* A store that is used for storing user schedules (and the active schedule)
|
* A store that is used for storing user schedules (and the active schedule)
|
||||||
*/
|
*/
|
||||||
export const UserScheduleStore = createLocalStore<IUserScheduleStore>({
|
export const UserScheduleStore = createLocalStore<IUserScheduleStore>('UserScheduleStore', {
|
||||||
schedules: [
|
schedules: [
|
||||||
new UserSchedule({
|
new UserSchedule({
|
||||||
courses: [],
|
courses: [],
|
||||||
@@ -24,4 +24,4 @@ export const UserScheduleStore = createLocalStore<IUserScheduleStore>({
|
|||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
debugStore({ userScheduleStore: UserScheduleStore });
|
// debugStore({ userScheduleStore: UserScheduleStore });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
import { getCourseColors } from '@shared/util/colors';
|
import { getCourseColors } from '@shared/util/colors';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
import type { CourseMeeting } from './CourseMeeting';
|
import type { CourseMeeting } from './CourseMeeting';
|
||||||
import { CourseSchedule } from './CourseSchedule';
|
import { CourseSchedule } from './CourseSchedule';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a map of the days of the week that a class is taught, and the corresponding abbreviation
|
* a map of the days of the week that a class is taught, and the corresponding abbreviation
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
|
|
||||||
import type { Day } from './CourseMeeting';
|
import type { Day } from './CourseMeeting';
|
||||||
import { CourseMeeting, DAY_MAP } from './CourseMeeting';
|
import { CourseMeeting, DAY_MAP } from './CourseMeeting';
|
||||||
|
|||||||
40
src/shared/types/GitHubStats.ts
Normal file
40
src/shared/types/GitHubStats.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/* eslint-disable jsdoc/require-jsdoc */
|
||||||
|
export type TeamMember = {
|
||||||
|
name: string;
|
||||||
|
role: string[];
|
||||||
|
githubUsername: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubStats = {
|
||||||
|
commits: number;
|
||||||
|
linesAdded: number;
|
||||||
|
linesDeleted: number;
|
||||||
|
mergedPRs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GitHubStatsResult = {
|
||||||
|
adminGitHubStats: Record<string, GitHubStats>;
|
||||||
|
userGitHubStats: Record<string, GitHubStats>;
|
||||||
|
contributors: string[];
|
||||||
|
names: Record<string, string>;
|
||||||
|
dataFetched: Date;
|
||||||
|
lastUpdated: Date;
|
||||||
|
isCached: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContributorStats = {
|
||||||
|
total: number;
|
||||||
|
weeks: { w: number; a: number; d: number; c: number }[];
|
||||||
|
author: { login: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContributorUser = {
|
||||||
|
name: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchResult<T> = {
|
||||||
|
data: T;
|
||||||
|
dataFetched: Date;
|
||||||
|
lastUpdated: Date;
|
||||||
|
isCached: boolean;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
import { capitalize } from '@shared/util/string';
|
import { capitalize } from '@shared/util/string';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type representing an instructor for a course (who teaches it)
|
* A type representing an instructor for a course (who teaches it)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
|
|
||||||
import { generateRandomId } from '../util/random';
|
import { generateRandomId } from '../util/random';
|
||||||
import { Course } from './Course';
|
import { Course } from './Course';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
import { theme } from 'unocss/preset-mini';
|
import { theme } from 'unocss/preset-mini';
|
||||||
|
|
||||||
import type { HexColor, HSL, Lab, RGB, sRGB } from '../types/Color';
|
import type { HexColor, HSL, Lab, RGB, sRGB } from '../types/Color';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import { generateRandomId } from '@shared/util/random';
|
import { generateRandomId } from '@shared/util/random';
|
||||||
@@ -7,7 +8,6 @@ import ScheduleDropdown from '@views/components/common/ScheduleDropdown';
|
|||||||
import ScheduleListItem from '@views/components/common/ScheduleListItem';
|
import ScheduleListItem from '@views/components/common/ScheduleListItem';
|
||||||
import { SortableList } from '@views/components/common/SortableList';
|
import { SortableList } from '@views/components/common/SortableList';
|
||||||
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
import { exampleSchedule } from '../injected/mocked';
|
import { exampleSchedule } from '../injected/mocked';
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function PopupMain(): JSX.Element {
|
|||||||
|
|
||||||
initAllSettings();
|
initAllSettings();
|
||||||
|
|
||||||
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
|
const l1 = OptionsStore.subscribe('enableCourseStatusChips', async ({ newValue }) => {
|
||||||
setEnableCourseStatusChips(newValue);
|
setEnableCourseStatusChips(newValue);
|
||||||
// console.log('enableCourseStatusChips', newValue);
|
// console.log('enableCourseStatusChips', newValue);
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,7 @@ export default function PopupMain(): JSX.Element {
|
|||||||
// });
|
// });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
OptionsStore.removeListener(l1);
|
OptionsStore.unsubscribe(l1);
|
||||||
// OptionsStore.removeListener(l2);
|
// OptionsStore.removeListener(l2);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MessageListener } from '@chrome-extension-toolkit';
|
||||||
import { Sidebar } from '@phosphor-icons/react';
|
import { Sidebar } from '@phosphor-icons/react';
|
||||||
import type { CalendarTabMessages } from '@shared/messages/CalendarMessages';
|
import type { CalendarTabMessages } from '@shared/messages/CalendarMessages';
|
||||||
import { OptionsStore } from '@shared/storage/OptionsStore';
|
import { OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
@@ -16,7 +17,6 @@ import { CalendarContext } from '@views/contexts/CalendarContext';
|
|||||||
import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
|
import useCourseFromUrl from '@views/hooks/useCourseFromUrl';
|
||||||
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
|
import { useFlattenedCourseSchedule } from '@views/hooks/useFlattenedCourseSchedule';
|
||||||
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
|
import useWhatsNewPopUp from '@views/hooks/useWhatsNew';
|
||||||
import { MessageListener } from 'chrome-extension-toolkit';
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|||||||
@@ -61,13 +61,13 @@ export default function CalendarCourseCell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initSettings().then(({ enableCourseStatusChips }) => setEnableCourseStatusChips(enableCourseStatusChips));
|
initSettings().then(({ enableCourseStatusChips }) => setEnableCourseStatusChips(enableCourseStatusChips));
|
||||||
|
|
||||||
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
|
const l1 = OptionsStore.subscribe('enableCourseStatusChips', async ({ newValue }) => {
|
||||||
setEnableCourseStatusChips(newValue);
|
setEnableCourseStatusChips(newValue);
|
||||||
// console.log('enableCourseStatusChips', newValue);
|
// console.log('enableCourseStatusChips', newValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
OptionsStore.removeListener(l1);
|
OptionsStore.unsubscribe(l1);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,7 @@ export default function CourseCellColorPicker({ defaultColor }: CourseCellColorP
|
|||||||
<>
|
<>
|
||||||
<Divider orientation='horizontal' size='100%' className='my-1' />
|
<Divider orientation='horizontal' size='100%' className='my-1' />
|
||||||
<div className='grid grid-cols-6 gap-1'>
|
<div className='grid grid-cols-6 gap-1'>
|
||||||
{colorPatchColors
|
{colorPatchColors.get(selectedBaseColor)?.map(shadeColor => (
|
||||||
.get(selectedBaseColor)
|
|
||||||
?.map(shadeColor => (
|
|
||||||
<ColorPatch
|
<ColorPatch
|
||||||
key={shadeColor}
|
key={shadeColor}
|
||||||
color={shadeColor}
|
color={shadeColor}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||||
import { CalendarDots, Export, FileCode, FilePng, Sidebar } from '@phosphor-icons/react';
|
import { CalendarDots, Export, FileCode, FilePng, FileText, Sidebar } from '@phosphor-icons/react';
|
||||||
import styles from '@views/components/calendar/CalendarHeader/CalendarHeader.module.scss';
|
import styles from '@views/components/calendar/CalendarHeader/CalendarHeader.module.scss';
|
||||||
import { Button } from '@views/components/common/Button';
|
import { Button } from '@views/components/common/Button';
|
||||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||||
@@ -11,7 +11,7 @@ import useSchedules from '@views/hooks/useSchedules';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { handleExportJson, saveAsCal, saveCalAsPng } from '../utils';
|
import { handleExportJson, saveAsCal, saveAsText, saveCalAsPng } from '../utils';
|
||||||
|
|
||||||
interface CalendarHeaderProps {
|
interface CalendarHeaderProps {
|
||||||
sidebarOpen?: boolean;
|
sidebarOpen?: boolean;
|
||||||
@@ -111,6 +111,18 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
|||||||
Save as .json
|
Save as .json
|
||||||
</Button>
|
</Button>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
<Button
|
||||||
|
className='w-full flex justify-start'
|
||||||
|
onClick={saveAsText}
|
||||||
|
color='ut-black'
|
||||||
|
size='small'
|
||||||
|
variant='minimal'
|
||||||
|
icon={FileText}
|
||||||
|
>
|
||||||
|
Save as .txt
|
||||||
|
</Button>
|
||||||
|
</MenuItem>
|
||||||
{/* <MenuItem>
|
{/* <MenuItem>
|
||||||
<Button color='ut-black' size='small' variant='minimal' icon={FileTxt}>
|
<Button color='ut-black' size='small' variant='minimal' icon={FileTxt}>
|
||||||
Export Unique IDs
|
Export Unique IDs
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function CalendarSchedules() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-w-full w-0 flex flex-col items-center gap-y-spacing-2'>
|
<div className='min-w-full w-0 flex flex-col items-center gap-y-spacing-2'>
|
||||||
<div className='m0 w-full flex justify-between items-center'>
|
<div className='m0 w-full flex items-center justify-between'>
|
||||||
<Text variant='h3' className='text-nowrap text-theme-black'>
|
<Text variant='h3' className='text-nowrap text-theme-black'>
|
||||||
MY SCHEDULES
|
MY SCHEDULES
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
import { tz } from '@date-fns/tz';
|
import { tz } from '@date-fns/tz';
|
||||||
import { Course } from '@shared/types/Course';
|
import { Course } from '@shared/types/Course';
|
||||||
import { UserSchedule } from '@shared/types/UserSchedule';
|
import { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
|
||||||
import { format as formatDate, parseISO } from 'date-fns';
|
import { format as formatDate, parseISO } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
chatterjeeCS429Course,
|
chatterjeeCS429Course,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Serialized } from '@chrome-extension-toolkit';
|
||||||
import { tz, TZDate } from '@date-fns/tz';
|
import { tz, TZDate } from '@date-fns/tz';
|
||||||
import exportSchedule from '@pages/background/lib/exportSchedule';
|
import exportSchedule from '@pages/background/lib/exportSchedule';
|
||||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||||
@@ -8,7 +9,6 @@ import type { UserSchedule } from '@shared/types/UserSchedule';
|
|||||||
import { downloadBlob } from '@shared/util/downloadBlob';
|
import { downloadBlob } from '@shared/util/downloadBlob';
|
||||||
import { englishStringifyList } from '@shared/util/string';
|
import { englishStringifyList } from '@shared/util/string';
|
||||||
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
import type { CalendarGridCourse } from '@views/hooks/useFlattenedCourseSchedule';
|
||||||
import type { Serialized } from 'chrome-extension-toolkit';
|
|
||||||
import type { DateArg, Day } from 'date-fns';
|
import type { DateArg, Day } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@@ -245,6 +245,27 @@ export const scheduleToIcsString = (schedule: Serialized<UserSchedule>) => {
|
|||||||
return icsString;
|
return icsString;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the provided schedule in a human readable/copyable text format
|
||||||
|
* @param schedule - The schedule object
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const scheduleToText = (schedule: Serialized<UserSchedule>) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(`Schedule: ${schedule.name}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
for (const c of schedule.courses) {
|
||||||
|
lines.push(c.fullName);
|
||||||
|
lines.push(`${c.creditHours} Credit Hours`);
|
||||||
|
lines.push(`${c.uniqueId}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the current schedule as a calendar file in the iCalendar format (ICS).
|
* Saves the current schedule as a calendar file in the iCalendar format (ICS).
|
||||||
* Fetches the current active schedule and converts it into an ICS string.
|
* Fetches the current active schedule and converts it into an ICS string.
|
||||||
@@ -262,6 +283,25 @@ export const saveAsCal = async () => {
|
|||||||
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
|
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current schedule as a plain text file consisting of
|
||||||
|
* Course Name - Course ID
|
||||||
|
* Course Time
|
||||||
|
* Unique Number
|
||||||
|
* Line Break
|
||||||
|
* Repeat
|
||||||
|
*/
|
||||||
|
export const saveAsText = async () => {
|
||||||
|
const schedule = await getSchedule();
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new Error('No schedule found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleText = scheduleToText(schedule);
|
||||||
|
downloadBlob(scheduleText, 'TEXT', 'schedule.txt');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves current schedule to JSON that can be imported on other devices.
|
* Saves current schedule to JSON that can be imported on other devices.
|
||||||
* @param id - Provided schedule ID to download
|
* @param id - Provided schedule ID to download
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function PopupCourseBlock({ className, course, colors }: PopupCou
|
|||||||
|
|
||||||
initAllSettings();
|
initAllSettings();
|
||||||
|
|
||||||
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
|
const l1 = OptionsStore.subscribe('enableCourseStatusChips', async ({ newValue }) => {
|
||||||
setEnableCourseStatusChips(newValue);
|
setEnableCourseStatusChips(newValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export default function PopupCourseBlock({ className, course, colors }: PopupCou
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
OptionsStore.removeListener(l1);
|
OptionsStore.unsubscribe(l1);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Delete' && !isEditing) {
|
||||||
|
// Check if any popups/dialogs are open by looking for open menu elements
|
||||||
|
const openMenus = document.querySelectorAll('[data-headlessui-state="open"]');
|
||||||
|
const openDialogs = document.querySelectorAll('[role="dialog"]');
|
||||||
|
|
||||||
|
if (openMenus.length === 0 && openDialogs.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const editorRef = React.useRef<HTMLInputElement>(null);
|
const editorRef = React.useRef<HTMLInputElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
@@ -108,6 +121,10 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
if (schedule.courses.length <= 0) {
|
||||||
|
deleteSchedule(schedule.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
showDialog({
|
showDialog({
|
||||||
title: 'Delete schedule?',
|
title: 'Delete schedule?',
|
||||||
description: (
|
description: (
|
||||||
@@ -142,7 +159,7 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='h-7.5 rounded bg-white'>
|
<div className='h-7.5 rounded bg-white' tabIndex={0} onKeyDown={handleKeyDown}>
|
||||||
<div className='h-full w-full flex cursor-pointer items-center gap-[1px] text-ut-burntorange'>
|
<div className='h-full w-full flex cursor-pointer items-center gap-[1px] text-ut-burntorange'>
|
||||||
{IS_STORYBOOK ? (
|
{IS_STORYBOOK ? (
|
||||||
<DotsSixVertical
|
<DotsSixVertical
|
||||||
@@ -182,6 +199,9 @@ export default function ScheduleListItem({ schedule, onClick }: ScheduleListItem
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Delete') {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
|||||||
44
src/views/components/common/Tooltip.tsx
Normal file
44
src/views/components/common/Tooltip.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
content: ReactNode;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tooltip that displays content on hover
|
||||||
|
*/
|
||||||
|
export default function Tooltip({
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
content,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
maxWidth,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<TooltipProps>): JSX.Element {
|
||||||
|
return (
|
||||||
|
<span className={clsx('relative inline-flex group', className)}>
|
||||||
|
{children}
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'pointer-events-none absolute rounded-md bg-white px-3 py-2 text-xs invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100 whitespace-normal break-words',
|
||||||
|
contentClassName
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
marginTop: offsetY,
|
||||||
|
marginLeft: offsetX,
|
||||||
|
maxWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Distribution, LetterGrade } from '@shared/types/Distribution';
|
|||||||
import { extendedColors } from '@shared/types/ThemeColors';
|
import { extendedColors } from '@shared/types/ThemeColors';
|
||||||
import Link from '@views/components/common/Link';
|
import Link from '@views/components/common/Link';
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
|
import Tooltip from '@views/components/common/Tooltip';
|
||||||
import {
|
import {
|
||||||
NoDataError,
|
NoDataError,
|
||||||
queryAggregateDistribution,
|
queryAggregateDistribution,
|
||||||
@@ -12,10 +13,12 @@ import Highcharts from 'highcharts';
|
|||||||
import HighchartsReact from 'highcharts-react-official';
|
import HighchartsReact from 'highcharts-react-official';
|
||||||
import type { ChangeEvent } from 'react';
|
import type { ChangeEvent } from 'react';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import Skeleton from 'react-loading-skeleton';
|
import Skeleton from 'react-loading-skeleton';
|
||||||
|
|
||||||
const UT_GRADE_DISTRIBUTION_URL = 'https://reports.utexas.edu/spotlight-data/ut-course-grade-distributions';
|
const UT_GRADE_DISTRIBUTION_URL = 'https://reports.utexas.edu/spotlight-data/ut-course-grade-distributions';
|
||||||
|
const TOOLTIP_CONTENT =
|
||||||
|
"The 'Other' grade category includes all non-standard letter grades, including: In Progress, Incomplete, Permanent Incomplete, Oblit, Q-Drop, Withdrawn, Credit, No Credit, Satisfactory, Unsatisfactory, and Registered on CR/F or CR/NC basis.";
|
||||||
interface GradeDistributionProps {
|
interface GradeDistributionProps {
|
||||||
course: Course;
|
course: Course;
|
||||||
}
|
}
|
||||||
@@ -126,6 +129,25 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
lineHeight: 'normal',
|
lineHeight: 'normal',
|
||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
},
|
},
|
||||||
|
useHTML: true,
|
||||||
|
formatter() {
|
||||||
|
// eslint-disable-next-line react/no-this-in-sfc
|
||||||
|
const val = `${this.value}`;
|
||||||
|
|
||||||
|
return val === 'Other'
|
||||||
|
? renderToStaticMarkup(
|
||||||
|
<Tooltip
|
||||||
|
content={TOOLTIP_CONTENT}
|
||||||
|
className='underline'
|
||||||
|
offsetX={-425}
|
||||||
|
offsetY={-175}
|
||||||
|
maxWidth={500}
|
||||||
|
>
|
||||||
|
Other
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
: val;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
text: 'Grades',
|
text: 'Grades',
|
||||||
@@ -135,6 +157,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
|||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F', 'Other'],
|
categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F', 'Other'],
|
||||||
tickInterval: 1,
|
tickInterval: 1,
|
||||||
tickWidth: 1,
|
tickWidth: 1,
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ export default function TableRow({ row, isSelected, activeSchedule, onClick }: P
|
|||||||
setHighlightConflicts(enableHighlightConflicts);
|
setHighlightConflicts(enableHighlightConflicts);
|
||||||
});
|
});
|
||||||
|
|
||||||
const l1 = OptionsStore.listen('enableHighlightConflicts', async ({ newValue }) => {
|
const l1 = OptionsStore.subscribe('enableHighlightConflicts', async ({ newValue }) => {
|
||||||
setHighlightConflicts(newValue);
|
setHighlightConflicts(newValue);
|
||||||
// console.log('enableHighlightConflicts', newValue);
|
// console.log('enableHighlightConflicts', newValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove listeners when the component is unmounted
|
// Remove listeners when the component is unmounted
|
||||||
return () => {
|
return () => {
|
||||||
OptionsStore.removeListener(l1);
|
OptionsStore.unsubscribe(l1);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
197
src/views/components/settings/AdvancedSettings.tsx
Normal file
197
src/views/components/settings/AdvancedSettings.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { Trash } from '@phosphor-icons/react';
|
||||||
|
import { OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
|
import MIMEType from '@shared/types/MIMEType';
|
||||||
|
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||||
|
import { handleExportJson } from '@views/components/calendar/utils';
|
||||||
|
import { Button } from '@views/components/common/Button';
|
||||||
|
import Divider from '@views/components/common/Divider';
|
||||||
|
import SwitchButton from '@views/components/common/SwitchButton';
|
||||||
|
import Text from '@views/components/common/Text/Text';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import FileUpload from '../common/FileUpload';
|
||||||
|
import { DISPLAY_PREVIEWS, PREVIEW_SECTION_DIV_CLASSNAME } from './constants';
|
||||||
|
import Preview from './Preview';
|
||||||
|
|
||||||
|
interface AdvancedSettingsProps {
|
||||||
|
highlightConflicts: boolean;
|
||||||
|
setHighlightConflicts: (value: boolean) => void;
|
||||||
|
loadAllCourses: boolean;
|
||||||
|
setLoadAllCourses: (value: boolean) => void;
|
||||||
|
increaseScheduleLimit: boolean;
|
||||||
|
setIncreaseScheduleLimit: (value: boolean) => void;
|
||||||
|
calendarNewTab: boolean;
|
||||||
|
setCalendarNewTab: (value: boolean) => void;
|
||||||
|
activeSchedule: UserSchedule;
|
||||||
|
handleEraseAll: () => void;
|
||||||
|
handleImportClick: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings section component for advanced settings
|
||||||
|
*/
|
||||||
|
export const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
||||||
|
highlightConflicts,
|
||||||
|
setHighlightConflicts,
|
||||||
|
loadAllCourses,
|
||||||
|
setLoadAllCourses,
|
||||||
|
increaseScheduleLimit,
|
||||||
|
setIncreaseScheduleLimit,
|
||||||
|
calendarNewTab,
|
||||||
|
setCalendarNewTab,
|
||||||
|
activeSchedule,
|
||||||
|
handleEraseAll,
|
||||||
|
handleImportClick,
|
||||||
|
}) => (
|
||||||
|
<section className='mb-8'>
|
||||||
|
<h2 className='mb-4 text-xl text-ut-black font-semibold'>ADVANCED SETTINGS</h2>
|
||||||
|
<div className='flex space-x-4'>
|
||||||
|
<div className={PREVIEW_SECTION_DIV_CLASSNAME}>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='max-w-xs'>
|
||||||
|
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||||
|
Export Current Schedule
|
||||||
|
</Text>
|
||||||
|
<p className='text-sm text-gray-600'>Backup your active schedule to a portable file</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
color='ut-burntorange'
|
||||||
|
onClick={() => handleExportJson(activeSchedule.id)}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider size='auto' orientation='horizontal' />
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='max-w-xs'>
|
||||||
|
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||||
|
Import Schedule
|
||||||
|
</Text>
|
||||||
|
<p className='text-sm text-gray-600'>Import from a schedule file</p>
|
||||||
|
</div>
|
||||||
|
<FileUpload
|
||||||
|
variant='filled'
|
||||||
|
color='ut-burntorange'
|
||||||
|
onChange={handleImportClick}
|
||||||
|
accept={MIMEType.JSON}
|
||||||
|
>
|
||||||
|
Import Schedule
|
||||||
|
</FileUpload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider size='auto' orientation='horizontal' />
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='max-w-xs'>
|
||||||
|
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||||
|
Course Conflict Highlight
|
||||||
|
</Text>
|
||||||
|
<p className='text-sm text-gray-600'>
|
||||||
|
Adds a red strikethrough to courses that have conflicting times.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SwitchButton
|
||||||
|
isChecked={highlightConflicts}
|
||||||
|
onChange={() => {
|
||||||
|
setHighlightConflicts(!highlightConflicts);
|
||||||
|
OptionsStore.set('enableHighlightConflicts', !highlightConflicts);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider size='auto' orientation='horizontal' />
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='max-w-xs'>
|
||||||
|
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||||
|
Load All Courses in Course Schedule
|
||||||
|
</Text>
|
||||||
|
<p className='text-sm text-gray-600'>
|
||||||
|
Loads all courses in the Course Schedule site by scrolling, instead of using next/prev page
|
||||||
|
buttons.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SwitchButton
|
||||||
|
isChecked={loadAllCourses}
|
||||||
|
onChange={() => {
|
||||||
|
setLoadAllCourses(!loadAllCourses);
|
||||||
|
OptionsStore.set('enableScrollToLoad', !loadAllCourses);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider size='auto' orientation='horizontal' />
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='max-w-xs'>
|
||||||
|
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||||
|
Allow more than 10 schedules
|
||||||
|
</Text>
|
||||||
|
<p className='text-sm text-gray-600'>
|
||||||
|
Allow bypassing the 10-schedule limit. Intended for advisors or staff who need to create
|
||||||
|
many schedules on behalf of students.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SwitchButton
|
||||||
|
isChecked={increaseScheduleLimit}
|
||||||
|
onChange={() => {
|
||||||
|
setIncreaseScheduleLimit(!increaseScheduleLimit);
|
||||||
|
OptionsStore.set('allowMoreSchedules', !increaseScheduleLimit);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider size='auto' orientation='horizontal' />
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='max-w-xs'>
|
||||||
|
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||||
|
Always Open Calendar in New Tab
|
||||||
|
</Text>
|
||||||
|
<p className='text-sm text-gray-600'>
|
||||||
|
Always opens the calendar view in a new tab when navigating to the calendar page. May
|
||||||
|
prevent issues where the calendar refuses to open.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SwitchButton
|
||||||
|
isChecked={calendarNewTab}
|
||||||
|
onChange={() => {
|
||||||
|
setCalendarNewTab(!calendarNewTab);
|
||||||
|
OptionsStore.set('alwaysOpenCalendarInNewTab', !calendarNewTab);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider size='auto' orientation='horizontal' />
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='max-w-xs'>
|
||||||
|
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
||||||
|
Reset All Data
|
||||||
|
</Text>
|
||||||
|
<p className='text-sm text-gray-600'>Erases all schedules and courses you have.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant='outline' color='theme-red' icon={Trash} onClick={handleEraseAll}>
|
||||||
|
Erase All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{DISPLAY_PREVIEWS && (
|
||||||
|
<Preview>
|
||||||
|
<Text
|
||||||
|
variant='h2-course'
|
||||||
|
className={clsx('text-center text-theme-red font-normal', {
|
||||||
|
'line-through': highlightConflicts,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
01234 MWF 10:00 AM - 11:00 AM UTC 1.234
|
||||||
|
</Text>
|
||||||
|
</Preview>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
54
src/views/components/settings/ContributorCard.tsx
Normal file
54
src/views/components/settings/ContributorCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Text from '@views/components/common/Text/Text';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ContributorCardProps {
|
||||||
|
name: string;
|
||||||
|
githubUsername: string;
|
||||||
|
roles: string[];
|
||||||
|
stats?: {
|
||||||
|
commits: number;
|
||||||
|
linesAdded: number;
|
||||||
|
linesDeleted: number;
|
||||||
|
mergedPRs?: number;
|
||||||
|
};
|
||||||
|
showStats: boolean;
|
||||||
|
includeMergedPRs: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub contributor card component
|
||||||
|
*/
|
||||||
|
export const ContributorCard: React.FC<ContributorCardProps> = ({
|
||||||
|
name,
|
||||||
|
githubUsername,
|
||||||
|
roles,
|
||||||
|
stats,
|
||||||
|
showStats,
|
||||||
|
includeMergedPRs,
|
||||||
|
}) => (
|
||||||
|
<div className='border border-gray-300 rounded bg-ut-gray/10 p-4'>
|
||||||
|
<Text
|
||||||
|
variant='p'
|
||||||
|
className='text-ut-burntorange font-semibold hover:cursor-pointer'
|
||||||
|
onClick={() => window.open(`https://github.com/${githubUsername}`, '_blank')}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{roles.map(role => (
|
||||||
|
<p key={`${githubUsername}-${role}`} className='text-sm text-gray-600'>
|
||||||
|
{role}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{showStats && stats && (
|
||||||
|
<div className='mt-2'>
|
||||||
|
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
||||||
|
{includeMergedPRs && stats.mergedPRs !== undefined && (
|
||||||
|
<p className='text-xs'>Merged PRs: {stats.mergedPRs}</p>
|
||||||
|
)}
|
||||||
|
<p className='text-xs'>Commits: {stats.commits}</p>
|
||||||
|
<p className='text-xs text-ut-green'>{stats.linesAdded}++</p>
|
||||||
|
<p className='text-xs text-theme-red'>{stats.linesDeleted}--</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
14
src/views/components/settings/ContributorCardSkeleton.tsx
Normal file
14
src/views/components/settings/ContributorCardSkeleton.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight skeleton placeholder for contributor cards while data loads
|
||||||
|
*/
|
||||||
|
export const ContributorCardSkeleton: React.FC = () => (
|
||||||
|
<div className='animate-pulse border border-gray-300 rounded bg-ut-gray/10 p-4'>
|
||||||
|
<div className='mb-2 h-4 w-3/4 rounded bg-gray-300' />
|
||||||
|
<div className='mb-1 h-3 w-1/2 rounded bg-gray-300' />
|
||||||
|
<div className='h-3 w-1/4 rounded bg-gray-300' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ContributorCardSkeleton;
|
||||||
@@ -1,115 +1,72 @@
|
|||||||
// import addCourse from '@pages/background/lib/addCourse';
|
// Pages
|
||||||
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
|
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
|
||||||
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
|
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
|
||||||
import importSchedule from '@pages/background/lib/importSchedule';
|
import importSchedule from '@pages/background/lib/importSchedule';
|
||||||
import { CalendarDots, Trash } from '@phosphor-icons/react';
|
import { CalendarDots } from '@phosphor-icons/react';
|
||||||
|
// Shared
|
||||||
import { background } from '@shared/messages';
|
import { background } from '@shared/messages';
|
||||||
import { DevStore } from '@shared/storage/DevStore';
|
import { DevStore } from '@shared/storage/DevStore';
|
||||||
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||||
import { CRX_PAGES } from '@shared/types/CRXPages';
|
import { CRX_PAGES } from '@shared/types/CRXPages';
|
||||||
import MIMEType from '@shared/types/MIMEType';
|
import Particles from '@tsparticles/react';
|
||||||
// import { addCourseByUrl } from '@shared/util/courseUtils';
|
|
||||||
// import { getCourseColors } from '@shared/util/colors';
|
|
||||||
// import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
|
|
||||||
import { Button } from '@views/components/common/Button';
|
import { Button } from '@views/components/common/Button';
|
||||||
import { usePrompt } from '@views/components/common/DialogProvider/DialogProvider';
|
import { usePrompt } from '@views/components/common/DialogProvider/DialogProvider';
|
||||||
|
// Views
|
||||||
import Divider from '@views/components/common/Divider';
|
import Divider from '@views/components/common/Divider';
|
||||||
import { LargeLogo } from '@views/components/common/LogoIcon';
|
import { LargeLogo } from '@views/components/common/LogoIcon';
|
||||||
// import PopupCourseBlock from '@views/components/common/PopupCourseBlock';
|
|
||||||
import SwitchButton from '@views/components/common/SwitchButton';
|
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
|
// Hooks
|
||||||
import useChangelog from '@views/hooks/useChangelog';
|
import useChangelog from '@views/hooks/useChangelog';
|
||||||
import useSchedules from '@views/hooks/useSchedules';
|
import useSchedules from '@views/hooks/useSchedules';
|
||||||
// import { CourseCatalogScraper } from '@views/lib/CourseCatalogScraper';
|
|
||||||
// import getCourseTableRows from '@views/lib/getCourseTableRows';
|
|
||||||
import { GitHubStatsService, LONGHORN_DEVELOPERS_ADMINS, LONGHORN_DEVELOPERS_SWE } from '@views/lib/getGitHubStats';
|
import { GitHubStatsService, LONGHORN_DEVELOPERS_ADMINS, LONGHORN_DEVELOPERS_SWE } from '@views/lib/getGitHubStats';
|
||||||
// import { SiteSupport } from '@views/lib/getSiteSupport';
|
// Misc
|
||||||
import clsx from 'clsx';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
|
// Icons
|
||||||
import IconoirGitFork from '~icons/iconoir/git-fork';
|
import IconoirGitFork from '~icons/iconoir/git-fork';
|
||||||
|
|
||||||
import { handleExportJson } from '../calendar/utils';
|
|
||||||
// import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories';;
|
|
||||||
import FileUpload from '../common/FileUpload';
|
|
||||||
import { useMigrationDialog } from '../common/MigrationDialog';
|
import { useMigrationDialog } from '../common/MigrationDialog';
|
||||||
// import RefreshIcon from '~icons/material-symbols/refresh';
|
import { AdvancedSettings } from './AdvancedSettings';
|
||||||
|
import { DEV_MODE_CLICK_TARGET, INCLUDE_MERGED_PRS, STATS_TOGGLE_KEY } from './constants';
|
||||||
|
import { ContributorCard } from './ContributorCard';
|
||||||
|
import { ContributorCardSkeleton } from './ContributorCardSkeleton';
|
||||||
import DevMode from './DevMode';
|
import DevMode from './DevMode';
|
||||||
import Preview from './Preview';
|
import { useBirthdayCelebration } from './useBirthdayCelebration';
|
||||||
|
import { useDevMode } from './useDevMode';
|
||||||
|
|
||||||
const manifest = chrome.runtime.getManifest();
|
const manifest = chrome.runtime.getManifest();
|
||||||
|
|
||||||
const gitHubStatsService = new GitHubStatsService();
|
|
||||||
const includeMergedPRs = false;
|
|
||||||
|
|
||||||
const DISPLAY_PREVIEWS = false;
|
|
||||||
const PREVIEW_SECTION_DIV_CLASSNAME = DISPLAY_PREVIEWS ? 'w-1/2 space-y-4' : 'flex-grow space-y-4';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for enabling developer mode.
|
* Main Settings Component for managing user settings and preferences.
|
||||||
*
|
|
||||||
* @param targetCount - The target count to activate developer mode.
|
|
||||||
* @returns A tuple containing a boolean indicating if developer mode is active and a function to increment the count.
|
|
||||||
*/
|
|
||||||
const useDevMode = (targetCount: number): [boolean, () => void] => {
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const [active, setActive] = useState(false);
|
|
||||||
const [lastClick, setLastClick] = useState(0);
|
|
||||||
|
|
||||||
const incrementCount = useCallback(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastClick < 500) {
|
|
||||||
setCount(prevCount => {
|
|
||||||
const newCount = prevCount + 1;
|
|
||||||
if (newCount === targetCount) {
|
|
||||||
setActive(true);
|
|
||||||
}
|
|
||||||
return newCount;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCount(1);
|
|
||||||
}
|
|
||||||
setLastClick(now);
|
|
||||||
}, [lastClick, targetCount]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setCount(0), 3000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [count]);
|
|
||||||
|
|
||||||
return [active, incrementCount];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component for managing user settings and preferences.
|
|
||||||
*
|
*
|
||||||
* @returns The Settings component.
|
* @returns The Settings component.
|
||||||
*/
|
*/
|
||||||
export default function Settings(): JSX.Element {
|
export default function Settings(): JSX.Element {
|
||||||
const [_enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
|
const gitHubStatsService = useMemo(() => new GitHubStatsService(), []);
|
||||||
// const [_showTimeLocation, setShowTimeLocation] = useState<boolean>(false);
|
|
||||||
const [highlightConflicts, setHighlightConflicts] = useState<boolean>(false);
|
|
||||||
const [loadAllCourses, setLoadAllCourses] = useState<boolean>(false);
|
|
||||||
const [_enableDataRefreshing, setEnableDataRefreshing] = useState<boolean>(false);
|
|
||||||
const [calendarNewTab, setCalendarNewTab] = useState<boolean>(false);
|
|
||||||
const [increaseScheduleLimit, setIncreaseScheduleLimit] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const showMigrationDialog = useMigrationDialog();
|
// State
|
||||||
|
const [highlightConflicts, setHighlightConflicts] = useState(false);
|
||||||
// Toggle GitHub stats when the user presses the 'S' key
|
const [loadAllCourses, setLoadAllCourses] = useState(false);
|
||||||
const [showGitHubStats, setShowGitHubStats] = useState<boolean>(false);
|
const [calendarNewTab, setCalendarNewTab] = useState(false);
|
||||||
|
const [increaseScheduleLimit, setIncreaseScheduleLimit] = useState(false);
|
||||||
|
const [showGitHubStats, setShowGitHubStats] = useState(false);
|
||||||
const [githubStats, setGitHubStats] = useState<Awaited<
|
const [githubStats, setGitHubStats] = useState<Awaited<
|
||||||
ReturnType<typeof gitHubStatsService.fetchGitHubStats>
|
ReturnType<typeof gitHubStatsService.fetchGitHubStats>
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
|
const [isDeveloper, setIsDeveloper] = useState(false);
|
||||||
|
|
||||||
const [activeSchedule] = useSchedules();
|
const [activeSchedule] = useSchedules();
|
||||||
// const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const [isDeveloper, setIsDeveloper] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const showDialog = usePrompt();
|
const showDialog = usePrompt();
|
||||||
const handleChangelogOnClick = useChangelog();
|
const handleChangelogOnClick = useChangelog();
|
||||||
|
const showMigrationDialog = useMigrationDialog();
|
||||||
|
|
||||||
|
const [devMode, toggleDevMode] = useDevMode(DEV_MODE_CLICK_TARGET);
|
||||||
|
const { showParticles, particlesInit, particlesOptions, triggerCelebration, isBirthday } = useBirthdayCelebration();
|
||||||
|
|
||||||
|
// Stable skeleton ids to avoid using array index as keys
|
||||||
|
const skeletonIdsRef = useRef<string[]>(Array.from({ length: 8 }, (_, i) => `skeleton-${i}`));
|
||||||
|
|
||||||
|
// Initialize settings and listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchGitHubStats = async () => {
|
const fetchGitHubStats = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -121,19 +78,10 @@ export default function Settings(): JSX.Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initAndSetSettings = async () => {
|
const initAndSetSettings = async () => {
|
||||||
const {
|
const { enableHighlightConflicts, enableScrollToLoad, alwaysOpenCalendarInNewTab, allowMoreSchedules } =
|
||||||
enableCourseStatusChips,
|
await initSettings();
|
||||||
enableHighlightConflicts,
|
|
||||||
enableScrollToLoad,
|
|
||||||
enableDataRefreshing,
|
|
||||||
alwaysOpenCalendarInNewTab,
|
|
||||||
allowMoreSchedules,
|
|
||||||
} = await initSettings();
|
|
||||||
setEnableCourseStatusChips(enableCourseStatusChips);
|
|
||||||
// setShowTimeLocation(enableTimeAndLocationInPopup);
|
|
||||||
setHighlightConflicts(enableHighlightConflicts);
|
setHighlightConflicts(enableHighlightConflicts);
|
||||||
setLoadAllCourses(enableScrollToLoad);
|
setLoadAllCourses(enableScrollToLoad);
|
||||||
setEnableDataRefreshing(enableDataRefreshing);
|
|
||||||
setCalendarNewTab(alwaysOpenCalendarInNewTab);
|
setCalendarNewTab(alwaysOpenCalendarInNewTab);
|
||||||
setIncreaseScheduleLimit(allowMoreSchedules);
|
setIncreaseScheduleLimit(allowMoreSchedules);
|
||||||
};
|
};
|
||||||
@@ -143,79 +91,50 @@ export default function Settings(): JSX.Element {
|
|||||||
setIsDeveloper(isDev);
|
setIsDeveloper(isDev);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ds_l1 = DevStore.listen('isDeveloper', async ({ newValue }) => {
|
const handleKeyPress = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === STATS_TOGGLE_KEY || event.key === STATS_TOGGLE_KEY.toUpperCase()) {
|
||||||
|
setShowGitHubStats(prev => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listeners
|
||||||
|
const ds_l1 = DevStore.subscribe('isDeveloper', async ({ newValue }) => {
|
||||||
setIsDeveloper(newValue);
|
setIsDeveloper(newValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const l1 = OptionsStore.subscribe('enableHighlightConflicts', async ({ newValue }) => {
|
||||||
|
setHighlightConflicts(newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
const l2 = OptionsStore.subscribe('enableScrollToLoad', async ({ newValue }) => {
|
||||||
|
setLoadAllCourses(newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
const l3 = OptionsStore.subscribe('alwaysOpenCalendarInNewTab', async ({ newValue }) => {
|
||||||
|
setCalendarNewTab(newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
const l4 = OptionsStore.subscribe('allowMoreSchedules', async ({ newValue }) => {
|
||||||
|
setIncreaseScheduleLimit(newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
|
|
||||||
initDS();
|
initDS();
|
||||||
fetchGitHubStats();
|
fetchGitHubStats();
|
||||||
initAndSetSettings();
|
initAndSetSettings();
|
||||||
|
|
||||||
const handleKeyPress = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'S' || event.key === 's') {
|
|
||||||
setShowGitHubStats(prev => !prev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyPress);
|
|
||||||
|
|
||||||
// Listen for changes in the settings
|
|
||||||
const l1 = OptionsStore.listen('enableCourseStatusChips', async ({ newValue }) => {
|
|
||||||
setEnableCourseStatusChips(newValue);
|
|
||||||
// console.log('enableCourseStatusChips', newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
// const l2 = OptionsStore.listen('enableTimeAndLocationInPopup', async ({ newValue }) => {
|
|
||||||
// setShowTimeLocation(newValue);
|
|
||||||
// // console.log('enableTimeAndLocationInPopup', newValue);
|
|
||||||
// });
|
|
||||||
|
|
||||||
const l2 = OptionsStore.listen('enableHighlightConflicts', async ({ newValue }) => {
|
|
||||||
setHighlightConflicts(newValue);
|
|
||||||
// console.log('enableHighlightConflicts', newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
const l3 = OptionsStore.listen('enableScrollToLoad', async ({ newValue }) => {
|
|
||||||
setLoadAllCourses(newValue);
|
|
||||||
// console.log('enableScrollToLoad', newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
const l4 = OptionsStore.listen('enableDataRefreshing', async ({ newValue }) => {
|
|
||||||
setEnableDataRefreshing(newValue);
|
|
||||||
// console.log('enableDataRefreshing', newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
const l5 = OptionsStore.listen('alwaysOpenCalendarInNewTab', async ({ newValue }) => {
|
|
||||||
setCalendarNewTab(newValue);
|
|
||||||
// console.log('alwaysOpenCalendarInNewTab', newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
const l6 = OptionsStore.listen('alwaysOpenCalendarInNewTab', async ({ newValue }) => {
|
|
||||||
setCalendarNewTab(newValue);
|
|
||||||
// console.log('alwaysOpenCalendarInNewTab', newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
const l7 = OptionsStore.listen('allowMoreSchedules', async ({ newValue }) => {
|
|
||||||
setIncreaseScheduleLimit(newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove listeners when the component is unmounted
|
|
||||||
return () => {
|
return () => {
|
||||||
OptionsStore.removeListener(l1);
|
OptionsStore.unsubscribe(l1);
|
||||||
OptionsStore.removeListener(l2);
|
OptionsStore.unsubscribe(l2);
|
||||||
OptionsStore.removeListener(l3);
|
OptionsStore.unsubscribe(l3);
|
||||||
OptionsStore.removeListener(l4);
|
OptionsStore.unsubscribe(l4);
|
||||||
OptionsStore.removeListener(l5);
|
DevStore.unsubscribe(ds_l1);
|
||||||
OptionsStore.removeListener(l6);
|
|
||||||
OptionsStore.removeListener(l7);
|
|
||||||
|
|
||||||
DevStore.removeListener(ds_l1);
|
|
||||||
|
|
||||||
window.removeEventListener('keydown', handleKeyPress);
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [gitHubStatsService]);
|
||||||
|
|
||||||
const handleEraseAll = () => {
|
const handleEraseAll = useCallback(() => {
|
||||||
showDialog({
|
showDialog({
|
||||||
title: 'Erase All Course/Schedule Data',
|
title: 'Erase All Course/Schedule Data',
|
||||||
description: (
|
description: (
|
||||||
@@ -242,9 +161,9 @@ export default function Settings(): JSX.Element {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
};
|
}, [showDialog]);
|
||||||
|
|
||||||
const handleImportClick = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImportClick = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
@@ -257,16 +176,30 @@ export default function Settings(): JSX.Element {
|
|||||||
console.error('Error importing schedule:', error);
|
console.error('Error importing schedule:', error);
|
||||||
alert('Failed to import schedule. Make sure the file is a valid .json format.');
|
alert('Failed to import schedule. Make sure the file is a valid .json format.');
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
// const handleAddCourseByLink = async () => {
|
|
||||||
// // todo: Use a proper modal instead of a prompt
|
|
||||||
// const link: string | null = prompt('Enter course link');
|
|
||||||
// // Exit if the user cancels the prompt
|
|
||||||
// if (link === null) return;
|
|
||||||
// await addCourseByUrl(link, activeSchedule);
|
|
||||||
// };
|
|
||||||
|
|
||||||
const [devMode, toggleDevMode] = useDevMode(10);
|
const sortedContributors = useMemo(() => {
|
||||||
|
if (!githubStats) return LONGHORN_DEVELOPERS_SWE;
|
||||||
|
return [...LONGHORN_DEVELOPERS_SWE].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(githubStats.userGitHubStats[b.githubUsername]?.commits ?? 0) -
|
||||||
|
(githubStats.userGitHubStats[a.githubUsername]?.commits ?? 0)
|
||||||
|
);
|
||||||
|
}, [githubStats]);
|
||||||
|
|
||||||
|
const additionalContributors = useMemo(() => {
|
||||||
|
if (!githubStats) return [];
|
||||||
|
return Object.keys(githubStats.userGitHubStats)
|
||||||
|
.filter(
|
||||||
|
username =>
|
||||||
|
!LONGHORN_DEVELOPERS_ADMINS.some(admin => admin.githubUsername === username) &&
|
||||||
|
!LONGHORN_DEVELOPERS_SWE.some(swe => swe.githubUsername === username)
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(githubStats.userGitHubStats[b]?.commits ?? 0) - (githubStats.userGitHubStats[a]?.commits ?? 0)
|
||||||
|
);
|
||||||
|
}, [githubStats]);
|
||||||
|
|
||||||
if (devMode) {
|
if (devMode) {
|
||||||
DevStore.set('isDeveloper', true);
|
DevStore.set('isDeveloper', true);
|
||||||
@@ -274,13 +207,32 @@ export default function Settings(): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className='relative'>
|
||||||
|
{particlesInit && showParticles && (
|
||||||
|
<Particles
|
||||||
|
id='birthday-particles'
|
||||||
|
options={particlesOptions}
|
||||||
|
className='pointer-events-none absolute inset-0 z-50'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<header className='flex items-center gap-5 overflow-x-auto overflow-y-hidden border-b border-ut-offwhite px-7 py-4 md:overflow-x-hidden'>
|
<header className='flex items-center gap-5 overflow-x-auto overflow-y-hidden border-b border-ut-offwhite px-7 py-4 md:overflow-x-hidden'>
|
||||||
<LargeLogo />
|
<LargeLogo />
|
||||||
<Divider className='mx-2 self-center md:mx-4' size='2.5rem' orientation='vertical' />
|
<Divider className='mx-2 self-center md:mx-4' size='2.5rem' orientation='vertical' />
|
||||||
<Text variant='h1' className='flex-1 text-ut-burntorange normal-case!'>
|
<div className='flex flex-1 items-center gap-2'>
|
||||||
|
<Text variant='h1' className='text-ut-burntorange normal-case'>
|
||||||
Settings and Credits
|
Settings and Credits
|
||||||
</Text>
|
</Text>
|
||||||
|
{isBirthday && (
|
||||||
|
<span
|
||||||
|
onClick={triggerCelebration}
|
||||||
|
className='cursor-pointer px-4 text-sm text-ut-burntorange transition-transform hover:scale-110'
|
||||||
|
title='Click to celebrate!'
|
||||||
|
>
|
||||||
|
🎉 Happy Birthday LHD! 🎉
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className='hidden flex-row items-center justify-end gap-6 screenshot:hidden lg:flex'>
|
<div className='hidden flex-row items-center justify-end gap-6 screenshot:hidden lg:flex'>
|
||||||
<Button variant='minimal' color='theme-black' onClick={handleChangelogOnClick}>
|
<Button variant='minimal' color='theme-black' onClick={handleChangelogOnClick}>
|
||||||
<IconoirGitFork className='h-6 w-6 text-ut-gray' />
|
<IconoirGitFork className='h-6 w-6 text-ut-gray' />
|
||||||
@@ -301,238 +253,19 @@ export default function Settings(): JSX.Element {
|
|||||||
|
|
||||||
<div className='p-6 lg:flex'>
|
<div className='p-6 lg:flex'>
|
||||||
<div className='mr-4 lg:w-1/2 xl:w-xl'>
|
<div className='mr-4 lg:w-1/2 xl:w-xl'>
|
||||||
{/* <section className='mb-8'>
|
<AdvancedSettings
|
||||||
<h2 className='mb-4 text-xl text-ut-black font-semibold'>CUSTOMIZATION OPTIONS</h2>
|
highlightConflicts={highlightConflicts}
|
||||||
<div className='flex space-x-4'>
|
setHighlightConflicts={setHighlightConflicts}
|
||||||
<div className='w-1/2 space-y-4'>
|
loadAllCourses={loadAllCourses}
|
||||||
<div className='flex items-center justify-between'>
|
setLoadAllCourses={setLoadAllCourses}
|
||||||
<div className='max-w-xs'>
|
increaseScheduleLimit={increaseScheduleLimit}
|
||||||
<h3 className='text-ut-burntorange font-semibold'>Show Course Status</h3>
|
setIncreaseScheduleLimit={setIncreaseScheduleLimit}
|
||||||
<p className='text-sm text-gray-600'>
|
calendarNewTab={calendarNewTab}
|
||||||
Shows an indicator for waitlisted, cancelled, and closed courses.
|
setCalendarNewTab={setCalendarNewTab}
|
||||||
</p>
|
activeSchedule={activeSchedule}
|
||||||
</div>
|
handleEraseAll={handleEraseAll}
|
||||||
<SwitchButton
|
handleImportClick={handleImportClick}
|
||||||
isChecked={enableCourseStatusChips}
|
|
||||||
onChange={() => {
|
|
||||||
setEnableCourseStatusChips(!enableCourseStatusChips);
|
|
||||||
OptionsStore.set('enableCourseStatusChips', !enableCourseStatusChips);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' />
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<h3 className='text-ut-burntorange font-semibold'>
|
|
||||||
Show Time & Location in Popup
|
|
||||||
</h3>
|
|
||||||
<p className='text-sm text-gray-600'>
|
|
||||||
Shows the course's time and location in the extension's popup.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SwitchButton
|
|
||||||
isChecked={showTimeLocation}
|
|
||||||
onChange={() => {
|
|
||||||
setShowTimeLocation(!showTimeLocation);
|
|
||||||
OptionsStore.set('enableTimeAndLocationInPopup', !showTimeLocation);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{DISPLAY_PREVIEWS && (
|
|
||||||
<Preview>
|
|
||||||
<CalendarCourseCell
|
|
||||||
colors={getCourseColors('orange')}
|
|
||||||
courseDeptAndInstr={ExampleCourse.department}
|
|
||||||
className={ExampleCourse.number}
|
|
||||||
status={ExampleCourse.status}
|
|
||||||
timeAndLocation={ExampleCourse.schedule.meetings[0]!.getTimeString({
|
|
||||||
separator: '-',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<PopupCourseBlock colors={getCourseColors('orange')} course={ExampleCourse} />
|
|
||||||
</Preview>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' /> */}
|
|
||||||
|
|
||||||
<section className='mb-8'>
|
|
||||||
<h2 className='mb-4 text-xl text-ut-black font-semibold'>ADVANCED SETTINGS</h2>
|
|
||||||
<div className='flex space-x-4'>
|
|
||||||
<div className={PREVIEW_SECTION_DIV_CLASSNAME}>
|
|
||||||
{/* <div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<h3 className='text-ut-burntorange font-semibold'>Refresh Data</h3>
|
|
||||||
<p className='text-sm text-gray-600'>
|
|
||||||
Refreshes waitlist, course status, and other info with the latest data from
|
|
||||||
UT's site.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
color='ut-black'
|
|
||||||
icon={RefreshIcon}
|
|
||||||
onClick={() => console.log('Refresh clicked')}
|
|
||||||
disabled={!enableDataRefreshing}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' /> */}
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
|
||||||
Export Current Schedule
|
|
||||||
</Text>
|
|
||||||
<p className='text-sm text-gray-600'>
|
|
||||||
Backup your active schedule to a portable file
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
color='ut-burntorange'
|
|
||||||
onClick={() => handleExportJson(activeSchedule.id)}
|
|
||||||
>
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' />
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
|
||||||
Import Schedule
|
|
||||||
</Text>
|
|
||||||
<p className='text-sm text-gray-600'>Import from a schedule file</p>
|
|
||||||
</div>
|
|
||||||
<FileUpload
|
|
||||||
variant='filled'
|
|
||||||
color='ut-burntorange'
|
|
||||||
onChange={handleImportClick}
|
|
||||||
accept={MIMEType.JSON}
|
|
||||||
>
|
|
||||||
Import Schedule
|
|
||||||
</FileUpload>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' />
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
|
||||||
Course Conflict Highlight
|
|
||||||
</Text>
|
|
||||||
<p className='text-sm text-gray-600'>
|
|
||||||
Adds a red strikethrough to courses that have conflicting times.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SwitchButton
|
|
||||||
isChecked={highlightConflicts}
|
|
||||||
onChange={() => {
|
|
||||||
setHighlightConflicts(!highlightConflicts);
|
|
||||||
OptionsStore.set('enableHighlightConflicts', !highlightConflicts);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' />
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
|
||||||
Load All Courses in Course Schedule
|
|
||||||
</Text>
|
|
||||||
<p className='text-sm text-gray-600'>
|
|
||||||
Loads all courses in the Course Schedule site by scrolling, instead of using
|
|
||||||
next/prev page buttons.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SwitchButton
|
|
||||||
isChecked={loadAllCourses}
|
|
||||||
onChange={() => {
|
|
||||||
setLoadAllCourses(!loadAllCourses);
|
|
||||||
OptionsStore.set('enableScrollToLoad', !loadAllCourses);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
|
||||||
Allow more than 10 schedules
|
|
||||||
</Text>
|
|
||||||
<p className='text-sm text-gray-600'>
|
|
||||||
Allow bypassing the 10-schedule limit. Intended for advisors or staff who
|
|
||||||
need to create many schedules on behalf of students.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SwitchButton
|
|
||||||
isChecked={increaseScheduleLimit}
|
|
||||||
onChange={() => {
|
|
||||||
setIncreaseScheduleLimit(!increaseScheduleLimit);
|
|
||||||
OptionsStore.set('allowMoreSchedules', !increaseScheduleLimit);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' />
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
|
||||||
Always Open Calendar in New Tab
|
|
||||||
</Text>
|
|
||||||
<p className='text-sm text-gray-600'>
|
|
||||||
Always opens the calendar view in a new tab when navigating to the calendar
|
|
||||||
page. May prevent issues where the calendar refuses to open.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SwitchButton
|
|
||||||
isChecked={calendarNewTab}
|
|
||||||
onChange={() => {
|
|
||||||
setCalendarNewTab(!calendarNewTab);
|
|
||||||
OptionsStore.set('alwaysOpenCalendarInNewTab', !calendarNewTab);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' />
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='max-w-xs'>
|
|
||||||
<Text variant='h4' className='text-ut-burntorange font-semibold'>
|
|
||||||
Reset All Data
|
|
||||||
</Text>
|
|
||||||
<p className='text-sm text-gray-600'>
|
|
||||||
Erases all schedules and courses you have.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant='outline' color='theme-red' icon={Trash} onClick={handleEraseAll}>
|
|
||||||
Erase All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{DISPLAY_PREVIEWS && (
|
|
||||||
<Preview>
|
|
||||||
<Text
|
|
||||||
variant='h2-course'
|
|
||||||
className={clsx('text-center text-theme-red !font-normal', {
|
|
||||||
'line-through': highlightConflicts,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
01234 MWF 10:00 AM - 11:00 AM UTC 1.234
|
|
||||||
</Text>
|
|
||||||
</Preview>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' />
|
<Divider size='auto' orientation='horizontal' />
|
||||||
|
|
||||||
@@ -593,17 +326,21 @@ export default function Settings(): JSX.Element {
|
|||||||
Open Debug Page
|
Open Debug Page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider size='auto' orientation='horizontal' />
|
<Divider size='auto' orientation='horizontal' />
|
||||||
|
|
||||||
<Button variant='filled' color='ut-black' onClick={() => addCourseByURL(activeSchedule)}>
|
<Button
|
||||||
|
variant='filled'
|
||||||
|
color='ut-black'
|
||||||
|
onClick={() => addCourseByURL(activeSchedule)}
|
||||||
|
>
|
||||||
Add course by link
|
Add course by link
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='filled' color='ut-burntorange' onClick={showMigrationDialog}>
|
<Button variant='filled' color='ut-burntorange' onClick={showMigrationDialog}>
|
||||||
Show Migration Dialog
|
Show Migration Dialog
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -616,143 +353,44 @@ export default function Settings(): JSX.Element {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3'>
|
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3'>
|
||||||
{LONGHORN_DEVELOPERS_ADMINS.map(admin => (
|
{LONGHORN_DEVELOPERS_ADMINS.map(admin => (
|
||||||
<div
|
<ContributorCard
|
||||||
key={admin.githubUsername}
|
key={admin.githubUsername}
|
||||||
className='border border-gray-300 rounded bg-ut-gray/10 p-4'
|
name={admin.name}
|
||||||
>
|
githubUsername={admin.githubUsername}
|
||||||
<Text
|
roles={admin.role}
|
||||||
variant='p'
|
stats={githubStats?.adminGitHubStats[admin.githubUsername]}
|
||||||
className='text-ut-burntorange font-semibold hover:cursor-pointer'
|
showStats={showGitHubStats}
|
||||||
onClick={() =>
|
includeMergedPRs={INCLUDE_MERGED_PRS}
|
||||||
window.open(`https://github.com/${admin.githubUsername}`, '_blank')
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
{admin.name}
|
|
||||||
</Text>
|
|
||||||
{admin.role.map(role => (
|
|
||||||
<p key={admin.githubUsername} className='text-sm text-gray-600'>
|
|
||||||
{role}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
{showGitHubStats && githubStats && (
|
|
||||||
<div className='mt-2'>
|
|
||||||
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
|
||||||
{includeMergedPRs && (
|
|
||||||
<p className='text-xs'>
|
|
||||||
Merged PRS:{' '}
|
|
||||||
{githubStats.adminGitHubStats[admin.githubUsername]?.mergedPRs}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className='text-xs'>
|
|
||||||
Commits: {githubStats.adminGitHubStats[admin.githubUsername]?.commits}
|
|
||||||
</p>
|
|
||||||
<p className='text-xs text-ut-green'>
|
|
||||||
{githubStats.adminGitHubStats[admin.githubUsername]?.linesAdded} ++
|
|
||||||
</p>
|
|
||||||
<p className='text-xs text-theme-red'>
|
|
||||||
{githubStats.adminGitHubStats[admin.githubUsername]?.linesDeleted} --
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className='my-8'>
|
<section className='my-8'>
|
||||||
<h2 className='mb-4 text-xl text-ut-black font-semibold'>UTRP CONTRIBUTORS</h2>
|
<h2 className='mb-4 text-xl text-ut-black font-semibold'>UTRP CONTRIBUTORS</h2>
|
||||||
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3 xl:grid-cols-3'>
|
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3 xl:grid-cols-3'>
|
||||||
{LONGHORN_DEVELOPERS_SWE.sort(
|
{sortedContributors.map(swe => (
|
||||||
(a, b) =>
|
<ContributorCard
|
||||||
(githubStats?.userGitHubStats[b.githubUsername]?.commits ?? 0) -
|
|
||||||
(githubStats?.userGitHubStats[a.githubUsername]?.commits ?? 0)
|
|
||||||
).map(swe => (
|
|
||||||
<div
|
|
||||||
key={swe.githubUsername}
|
key={swe.githubUsername}
|
||||||
className='border border-gray-300 rounded bg-ut-gray/10 p-4'
|
name={swe.name}
|
||||||
>
|
githubUsername={swe.githubUsername}
|
||||||
<Text
|
roles={swe.role}
|
||||||
variant='p'
|
stats={githubStats?.userGitHubStats[swe.githubUsername]}
|
||||||
className='text-ut-burntorange font-semibold hover:cursor-pointer'
|
showStats={showGitHubStats}
|
||||||
onClick={() =>
|
includeMergedPRs={INCLUDE_MERGED_PRS}
|
||||||
window.open(`https://github.com/${swe.githubUsername}`, '_blank')
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
{swe.name}
|
|
||||||
</Text>
|
|
||||||
{swe.role.map(role => (
|
|
||||||
<p key={swe.githubUsername} className='text-sm text-gray-600'>
|
|
||||||
{role}
|
|
||||||
</p>
|
|
||||||
))}
|
))}
|
||||||
{showGitHubStats && githubStats && (
|
{githubStats === null
|
||||||
<div className='mt-2'>
|
? skeletonIdsRef.current.slice(0, 8).map(id => <ContributorCardSkeleton key={id} />)
|
||||||
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
: additionalContributors.map(username => (
|
||||||
{includeMergedPRs && (
|
<ContributorCard
|
||||||
<p className='text-xs'>
|
|
||||||
Merged PRS:{' '}
|
|
||||||
{githubStats.userGitHubStats[swe.githubUsername]?.mergedPRs}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className='text-xs'>
|
|
||||||
Commits: {githubStats.userGitHubStats[swe.githubUsername]?.commits}
|
|
||||||
</p>
|
|
||||||
<p className='text-xs text-ut-green'>
|
|
||||||
{githubStats.userGitHubStats[swe.githubUsername]?.linesAdded} ++
|
|
||||||
</p>
|
|
||||||
<p className='text-xs text-theme-red'>
|
|
||||||
{githubStats.userGitHubStats[swe.githubUsername]?.linesDeleted} --
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{githubStats &&
|
|
||||||
Object.keys(githubStats.userGitHubStats)
|
|
||||||
.filter(
|
|
||||||
username =>
|
|
||||||
!LONGHORN_DEVELOPERS_ADMINS.some(
|
|
||||||
admin => admin.githubUsername === username
|
|
||||||
) && !LONGHORN_DEVELOPERS_SWE.some(swe => swe.githubUsername === username)
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
(githubStats.userGitHubStats[b]?.commits ?? 0) -
|
|
||||||
(githubStats.userGitHubStats[a]?.commits ?? 0)
|
|
||||||
)
|
|
||||||
.map(username => (
|
|
||||||
<div
|
|
||||||
key={username}
|
key={username}
|
||||||
className='overflow-clip border border-gray-300 rounded bg-ut-gray/10 p-4'
|
name={githubStats!.names[username] || username}
|
||||||
>
|
githubUsername={username}
|
||||||
<Text
|
roles={['Contributor']}
|
||||||
variant='p'
|
stats={githubStats!.userGitHubStats[username]}
|
||||||
className='text-ut-burntorange font-semibold hover:cursor-pointer'
|
showStats={showGitHubStats}
|
||||||
onClick={() => window.open(`https://github.com/${username}`, '_blank')}
|
includeMergedPRs={INCLUDE_MERGED_PRS}
|
||||||
>
|
/>
|
||||||
{githubStats.names[username]}
|
|
||||||
</Text>
|
|
||||||
<p className='text-sm text-gray-600'>Contributor</p>
|
|
||||||
{showGitHubStats && (
|
|
||||||
<div className='mt-2'>
|
|
||||||
<p className='text-xs text-gray-500'>GitHub Stats (UTRP repo):</p>
|
|
||||||
{includeMergedPRs && (
|
|
||||||
<p className='text-xs'>
|
|
||||||
Merged PRs:{' '}
|
|
||||||
{githubStats.userGitHubStats[username]?.mergedPRs}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className='text-xs'>
|
|
||||||
Commits: {githubStats.userGitHubStats[username]?.commits}
|
|
||||||
</p>
|
|
||||||
<p className='text-xs text-ut-green'>
|
|
||||||
{githubStats.userGitHubStats[username]?.linesAdded} ++
|
|
||||||
</p>
|
|
||||||
<p className='text-xs text-theme-red'>
|
|
||||||
{githubStats.userGitHubStats[username]?.linesDeleted} --
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
13
src/views/components/settings/constants.ts
Normal file
13
src/views/components/settings/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const DISPLAY_PREVIEWS = false;
|
||||||
|
export const PREVIEW_SECTION_DIV_CLASSNAME = DISPLAY_PREVIEWS ? 'w-1/2 space-y-4' : 'flex-grow space-y-4';
|
||||||
|
|
||||||
|
export const STATS_TOGGLE_KEY = 's';
|
||||||
|
export const INCLUDE_MERGED_PRS = false;
|
||||||
|
export const DEV_MODE_CLICK_TARGET = 5;
|
||||||
|
export const DEV_MODE_CLICK_TIMEOUT = 5000;
|
||||||
|
export const DEV_MODE_CLICK_INTERVAL = 500;
|
||||||
|
|
||||||
|
// LHD Birthday: January 9th, 2024
|
||||||
|
export const LHD_BIRTHDAY = { month: 0, day: 9 };
|
||||||
|
export const BIRTHDAY_CELEBRATION_DURATION = 5000;
|
||||||
|
export const BIRTHDAY_CELEBRATION_DEBOUNCE = 2000;
|
||||||
140
src/views/components/settings/useBirthdayCelebration.ts
Normal file
140
src/views/components/settings/useBirthdayCelebration.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Engine, ISourceOptions } from '@tsparticles/engine';
|
||||||
|
import { initParticlesEngine } from '@tsparticles/react';
|
||||||
|
import { loadSlim } from '@tsparticles/slim';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { BIRTHDAY_CELEBRATION_DEBOUNCE, BIRTHDAY_CELEBRATION_DURATION, LHD_BIRTHDAY } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for birthday celebration particles
|
||||||
|
*/
|
||||||
|
export const useBirthdayCelebration = () => {
|
||||||
|
const [showParticles, setShowParticles] = useState(false);
|
||||||
|
const [particlesInit, setParticlesInit] = useState(false);
|
||||||
|
const [lastCelebration, setLastCelebration] = useState(0);
|
||||||
|
|
||||||
|
const isBirthday = useMemo(() => {
|
||||||
|
const today = new Date();
|
||||||
|
return today.getMonth() === LHD_BIRTHDAY.month && today.getDate() === LHD_BIRTHDAY.day;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initParticlesEngine(async (engine: Engine) => {
|
||||||
|
await loadSlim(engine);
|
||||||
|
}).then(() => {
|
||||||
|
setParticlesInit(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const triggerCelebration = useCallback(() => {
|
||||||
|
if (!isBirthday) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
// Debounce: prevent triggering again within BIRTHDAY_CELEBRATION_DEBOUNCE ms
|
||||||
|
if (now - lastCelebration < BIRTHDAY_CELEBRATION_DEBOUNCE) return;
|
||||||
|
|
||||||
|
setLastCelebration(now);
|
||||||
|
setShowParticles(true);
|
||||||
|
setTimeout(() => setShowParticles(false), BIRTHDAY_CELEBRATION_DURATION);
|
||||||
|
}, [isBirthday, lastCelebration]);
|
||||||
|
|
||||||
|
const particlesOptions: ISourceOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
fullScreen: { enable: true, zIndex: 1 },
|
||||||
|
particles: {
|
||||||
|
color: { value: ['#BF5700', '#333F48', '#FFFFFF'] }, // UT colors
|
||||||
|
move: {
|
||||||
|
direction: 'bottom',
|
||||||
|
enable: true,
|
||||||
|
outModes: {
|
||||||
|
default: 'out',
|
||||||
|
},
|
||||||
|
size: true,
|
||||||
|
speed: {
|
||||||
|
min: 1,
|
||||||
|
max: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
number: {
|
||||||
|
value: 500,
|
||||||
|
density: {
|
||||||
|
enable: true,
|
||||||
|
area: 800,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
value: 1,
|
||||||
|
animation: {
|
||||||
|
enable: false,
|
||||||
|
startValue: 'max',
|
||||||
|
destroy: 'min',
|
||||||
|
speed: 0.3,
|
||||||
|
sync: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rotate: {
|
||||||
|
value: {
|
||||||
|
min: 0,
|
||||||
|
max: 360,
|
||||||
|
},
|
||||||
|
direction: 'random',
|
||||||
|
move: true,
|
||||||
|
animation: {
|
||||||
|
enable: true,
|
||||||
|
speed: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tilt: {
|
||||||
|
direction: 'random',
|
||||||
|
enable: true,
|
||||||
|
move: true,
|
||||||
|
value: {
|
||||||
|
min: 0,
|
||||||
|
max: 360,
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
enable: true,
|
||||||
|
speed: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
type: ['circle', 'square'],
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
value: {
|
||||||
|
min: 2,
|
||||||
|
max: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
roll: {
|
||||||
|
darken: {
|
||||||
|
enable: true,
|
||||||
|
value: 30,
|
||||||
|
},
|
||||||
|
enlighten: {
|
||||||
|
enable: true,
|
||||||
|
value: 30,
|
||||||
|
},
|
||||||
|
enable: true,
|
||||||
|
speed: {
|
||||||
|
min: 15,
|
||||||
|
max: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wobble: {
|
||||||
|
distance: 30,
|
||||||
|
enable: true,
|
||||||
|
move: true,
|
||||||
|
speed: {
|
||||||
|
min: -15,
|
||||||
|
max: 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { showParticles, particlesInit, particlesOptions, triggerCelebration, isBirthday };
|
||||||
|
};
|
||||||
35
src/views/components/settings/useDevMode.ts
Normal file
35
src/views/components/settings/useDevMode.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { DEV_MODE_CLICK_INTERVAL, DEV_MODE_CLICK_TIMEOUT } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for enabling developer mode via rapid clicking
|
||||||
|
*/
|
||||||
|
export const useDevMode = (targetCount: number): [boolean, () => void] => {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [lastClick, setLastClick] = useState(0);
|
||||||
|
|
||||||
|
const incrementCount = useCallback(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastClick < DEV_MODE_CLICK_INTERVAL) {
|
||||||
|
setCount(prevCount => {
|
||||||
|
const newCount = prevCount + 1;
|
||||||
|
if (newCount === targetCount) {
|
||||||
|
setActive(true);
|
||||||
|
}
|
||||||
|
return newCount;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCount(1);
|
||||||
|
}
|
||||||
|
setLastClick(now);
|
||||||
|
}, [lastClick, targetCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setCount(0), DEV_MODE_CLICK_TIMEOUT);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
return [active, incrementCount];
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user