Compare commits
40 Commits
v2.2.1
...
derek/sett
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0939cab8f5 | ||
|
|
518a3286fa | ||
|
|
2d18553f98 | ||
|
|
68e3fe45fa | ||
|
|
38bb29b20b | ||
|
|
8ccf7fb37e | ||
|
|
b406d4dd24 | ||
|
|
d12c8bd7b7 | ||
|
|
ea5d1e2b16 | ||
|
|
c2b43dc949 | ||
| 8d2fdfa63c | |||
| c1ffd89dbe | |||
|
|
dd8187d6da | ||
| 5028d07f0b | |||
| 4f5d8c6d20 | |||
| f77f663fe7 | |||
|
|
6a67a32e4f | ||
|
|
549c52a39f | ||
|
|
36007b9d1a | ||
| cee5f0284f | |||
|
|
e8a8b8e1ae | ||
|
|
c21cbd77f0 | ||
| 99a035e29d | |||
|
|
64baa6d290 | ||
|
|
46fe591fa7 | ||
|
|
8f7e1bc0af | ||
|
|
9fc1098ef7 | ||
|
|
ae094416fc | ||
|
|
2e7dac1e3e | ||
|
|
7bea23a655 | ||
|
|
3d28869e92 | ||
|
|
f0f1f0b365 | ||
| be861b823c | |||
|
|
95de8df372 | ||
| 5994ded8be | |||
|
|
7b401add15 | ||
|
|
2d92dd47f0 | ||
|
|
eb8141ee8c | ||
|
|
2a50f5580d | ||
|
|
65bfb1d129 |
@@ -7,3 +7,6 @@ insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
|
||||
[*.{nix,yaml,yml}]
|
||||
indent_size = 2
|
||||
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
SENTRY_ORG=longhorn-developers
|
||||
SENTRY_PROJECT=ut-registration-plus
|
||||
SENTRY_AUTH_TOKEN=
|
||||
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG] "
|
||||
title: '[BUG] '
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Pre-submission Checklist**
|
||||
@@ -30,11 +29,11 @@ assignees: ''
|
||||
|
||||
**Expected Behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen -->
|
||||
<!-- What you expected to happen -->
|
||||
|
||||
**Current Behavior**
|
||||
|
||||
<!-- A clear and concise description of what actually happened -->
|
||||
<!-- What actually happened -->
|
||||
|
||||
**Screenshots**
|
||||
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE] "
|
||||
title: '[FEATURE] '
|
||||
labels: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Pre-submission Checklist**
|
||||
@@ -17,18 +16,14 @@ assignees: ''
|
||||
- [ ] I have reviewed the documentation to confirm this feature doesn't exist
|
||||
- [ ] I have completed all sections below with detailed information
|
||||
|
||||
**Feature Description**
|
||||
**Your Idea**
|
||||
|
||||
<!-- A clear and concise description of the feature you'd like to see -->
|
||||
|
||||
**Proposed Solution**
|
||||
|
||||
<!-- A clear and concise description of what you want to happen -->
|
||||
<!-- A clear and concise description of the feature you'd like to see, and how it would work -->
|
||||
|
||||
**UI/UX Considerations**
|
||||
|
||||
<!-- If this feature involves UI changes, please describe the visual aspects -->
|
||||
<!-- If this feature involves UI changes (aka how it looks), please describe the visual aspects -->
|
||||
|
||||
**Technical Implementation Details**
|
||||
**Other**
|
||||
|
||||
<!-- If you have specific technical suggestions, list them here -->
|
||||
<!-- Any other comments you have can go here! -->
|
||||
|
||||
@@ -4,7 +4,6 @@ about: Updating Build Dependencies
|
||||
title: ''
|
||||
labels: build, dependencies
|
||||
assignees: doprz, Razboy20
|
||||
|
||||
---
|
||||
|
||||
- [ ] Updated Nix Flake
|
||||
|
||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -15,7 +15,6 @@ updates:
|
||||
major-updates:
|
||||
update-types:
|
||||
- 'major'
|
||||
|
||||
ignore:
|
||||
- dependency-name: '@crxjs/vite-plugin'
|
||||
- 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
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run ESLint
|
||||
run: pnpm run lint
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: 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
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run check-types
|
||||
|
||||
4
.github/workflows/chromatic.yml
vendored
4
.github/workflows/chromatic.yml
vendored
@@ -1,7 +1,5 @@
|
||||
name: 'Chromatic'
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
chromatic:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -14,10 +12,8 @@ jobs:
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Publish to Chromatic
|
||||
uses: chromaui/action@latest
|
||||
with:
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -15,7 +15,6 @@ jobs:
|
||||
- uses: actions/checkout@master
|
||||
- name: Get file permission
|
||||
run: chmod -R 777 .
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Release with semantic-release
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -1,24 +1,18 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
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
|
||||
|
||||
# thank you ben limmer for this workflow:
|
||||
# https://github.com/blimmer/semantic-release-demo-2/blob/main/.github/workflows/lint-pr.yml
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
@@ -10,7 +8,6 @@ on:
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -211,3 +211,10 @@ sketch
|
||||
package-lock.json
|
||||
storybook-static/
|
||||
package/
|
||||
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -6,13 +6,9 @@
|
||||
"request": "launch",
|
||||
"name": "Run current script",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"tsx"
|
||||
],
|
||||
"runtimeArgs": ["tsx"],
|
||||
"program": "${file}",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -26,7 +26,7 @@
|
||||
"navigation": "Routes",
|
||||
"logging": "log",
|
||||
"popup": "Layout",
|
||||
"storage": "Database",
|
||||
"storage": "Database"
|
||||
},
|
||||
"material-icon-theme.files.associations": {
|
||||
"tsconfig.extension.json": "tsconfig",
|
||||
@@ -36,5 +36,5 @@
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,9 +1,43 @@
|
||||
## [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)
|
||||
|
||||
### Features
|
||||
|
||||
* add nix flake ([#593](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/593)) ([7b401ad](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/7b401add1565ff401bad99745ff9e53b9a7f899f))
|
||||
* 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))
|
||||
* 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))
|
||||
* 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))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix or ignore various eslint warning ([#609](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/609)) ([95de8df](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/95de8df37243b6d59625df515a60442f11b7a9d3))
|
||||
* limit height of schedule list dropdown in the extension popup ([#543](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/543)) ([eb8141e](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/eb8141ee8c3d32bce901457178d50781b78f86dd))
|
||||
* whitespace wrapping in semester warning ([#629](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/629)) ([46fe591](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/46fe591fa72ef017eea7cfb8aa37d12d8f223926))
|
||||
## [2.2.1](https://github.com/Longhorn-Developers/UT-Registration-Plus/compare/v2.2.0...v2.2.1) (2025-06-04)
|
||||
|
||||
### Features
|
||||
|
||||
* add dining app promo ([#598](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/598)) ([be1dccf](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/be1dccfcb9d052c6b291b50cc53418d6bb645beb))
|
||||
* inside jokes005 ([#590](https://github.com/Longhorn-Developers/UT-Registration-Plus/issues/590)) ([37471ef](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/37471efb740c7a5828cf3b54bac70954694359d7))
|
||||
* **release:** v2.2.1 ([234f3d6](https://github.com/Longhorn-Developers/UT-Registration-Plus/commit/234f3d627d603adf8555b4d0e93106d198918169))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
||||
32
CODE_OF_CONDUCT.md
Normal file
32
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# UT Registration Plus Code of Conduct
|
||||
|
||||
Like the technical community as a whole, the UT Registration Plus team and community is made up of a mixture of professionals and volunteers from all over the world, working on every aspect of the mission - including mentorship, teaching, and connecting people.
|
||||
|
||||
Diversity is one of our huge strengths, but it can also lead to communication issues and unhappiness. To that end, we have a few ground rules that we ask people to adhere to. This code applies equally to founders, mentors and those seeking help and guidance.
|
||||
|
||||
This isn’t an exhaustive list of things that you can’t do. Rather, take it in the spirit in which it’s intended - a guide to make it easier to enrich all of us and the technical communities in which we participate.
|
||||
|
||||
This code of conduct applies to all spaces managed by the UT Registration Plus project or Longhorn Developers. This includes IRC, the mailing lists, the issue tracker, DSF events, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them.
|
||||
|
||||
If you believe someone is violating the code of conduct, we ask that you report it by emailing [contact@longhorns.dev](mailto:contact@longhorns.dev). For more details please see our
|
||||
|
||||
- **Be friendly and patient.**
|
||||
- **Be welcoming.** We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, colour, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability.
|
||||
- **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 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.
|
||||
- Discriminatory jokes and language.
|
||||
- Posting sexually explicit or violent material.
|
||||
- Posting (or threatening to post) other people's personally identifying information ("doxing").
|
||||
- Personal insults, especially those using racist or sexist terms.
|
||||
- Unwelcome sexual attention.
|
||||
- Advocating for, or encouraging, any of the above behavior.
|
||||
- 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.
|
||||
|
||||
Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html).
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions, please see . If that doesn't answer your questions, feel free to [contact us](mailto:contact@longhorns.dev).
|
||||
12
README.md
12
README.md
@@ -26,8 +26,9 @@
|
||||
## Toolchain
|
||||
|
||||
- React v20.9.0 (LTS)
|
||||
- TypeScript
|
||||
- Vite 5
|
||||
- TypeScript v5.x
|
||||
- Vite v5.x
|
||||
- pnpm v10.x
|
||||
- UnoCSS
|
||||
- ESLint
|
||||
- Prettier
|
||||
@@ -184,8 +185,9 @@ We maintain a strict code of conduct. By contributing, you agree to adhere to th
|
||||
Special thanks to the developers and contributors behind these amazing tools and libraries:
|
||||
|
||||
- React v20.9.0 (LTS)
|
||||
- TypeScript
|
||||
- Vite 5
|
||||
- TypeScript v5.x
|
||||
- Vite v5.x
|
||||
- pnpm v10.x
|
||||
- UnoCSS
|
||||
- ESLint
|
||||
- Prettier
|
||||
@@ -203,7 +205,7 @@ Special thanks to the developers and contributors behind these amazing tools and
|
||||
|
||||
## Activity
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||
# Validate the mode
|
||||
if [[ ! " ${SUPPORTED_MODES[*]} " =~ " ${mode} " ]]; then
|
||||
if [[ ! " ${SUPPORTED_MODES[*]} " =~ ${mode} ]]; then
|
||||
echo "Error: Invalid mode '${mode}'" >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
0
docs/WebSocket-Implementation-Tutorial.md
Normal file
0
docs/WebSocket-Implementation-Tutorial.md
Normal file
96
flake.lock
generated
Normal file
96
flake.lock
generated
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767609335,
|
||||
"narHash": "sha256-feveD98mQpptwrAEggBQKJTYbvwwglSbOv53uCfH9PY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "250481aafeb741edfe23d29195671c19b36b6dca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767640445,
|
||||
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1765674936,
|
||||
"narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
33
flake.nix
Normal file
33
flake.nix
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||
treefmt-nix.url = "github:numtide/treefmt-nix";
|
||||
};
|
||||
|
||||
outputs =
|
||||
inputs@{ flake-parts, ... }:
|
||||
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = inputs.nixpkgs.lib.systems.flakeExposed;
|
||||
|
||||
imports = [
|
||||
./nix/packages.nix
|
||||
./nix/devShells.nix
|
||||
./nix/treefmt.nix
|
||||
];
|
||||
|
||||
perSystem =
|
||||
{ system, ... }:
|
||||
{
|
||||
_module.args.pkgs = import inputs.nixpkgs {
|
||||
inherit system;
|
||||
overlays = [
|
||||
(final: prev: {
|
||||
nodejs = prev.nodejs_20; # v20.19.5
|
||||
})
|
||||
];
|
||||
config = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
18
package.json
18
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ut-registration-plus",
|
||||
"displayName": "UT Registration Plus",
|
||||
"version": "2.2.1",
|
||||
"version": "2.3.0",
|
||||
"description": "UT Registration Plus is a Chrome extension that allows students to easily register for classes.",
|
||||
"private": true,
|
||||
"homepage": "https://github.com/Longhorn-Developers/UT-Registration-Plus",
|
||||
@@ -39,6 +39,9 @@
|
||||
"@phosphor-icons/react": "^2.1.7",
|
||||
"@sentry/react": "^8.55.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",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"chrome-extension-toolkit": "^0.0.54",
|
||||
@@ -136,7 +139,7 @@
|
||||
"gulp-zip": "^6.1.0",
|
||||
"path": "^0.12.7",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.2",
|
||||
"prettier": "3.6.2",
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"semantic-release": "^24.2.3",
|
||||
"storybook": "^8.6.0",
|
||||
@@ -144,7 +147,7 @@
|
||||
"unocss": "^0.63.6",
|
||||
"unocss-preset-primitives": "0.0.2-beta.1",
|
||||
"unplugin-icons": "^0.19.3",
|
||||
"vite": "^5.4.14",
|
||||
"vite": "^5.4.20",
|
||||
"vite-plugin-inspect": "^0.8.9",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
@@ -159,10 +162,13 @@
|
||||
},
|
||||
"overrides": {
|
||||
"es-module-lexer": "^1.5.4"
|
||||
}
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@tsparticles/engine"
|
||||
]
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.9.0",
|
||||
"pnpm": "10.6.5"
|
||||
"node": "20.19.4",
|
||||
"pnpm": "10.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
1423
pnpm-lock.yaml
generated
1423
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
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
|
||||
@@ -271,12 +271,12 @@ export default function Page404(): JSX.Element {
|
||||
}
|
||||
function _0x5629d1() {
|
||||
let _0x13c635 = _0xdd3699;
|
||||
_0x5b7f43(),
|
||||
(_0x5b7f43(),
|
||||
_0x16f39e[_0x13c635(0x81)](
|
||||
_0x228047,
|
||||
0x9c + -0x1 * 0x23ab + 0x230f * 0x1,
|
||||
-0x1c26 + 0x7bf + -0x6cd * -0x3
|
||||
);
|
||||
));
|
||||
}
|
||||
let _0x93f360 = 0x64 * 0x49 + 0x1e1e + -0x3aa2;
|
||||
function _0x5b7f43() {
|
||||
@@ -373,12 +373,12 @@ export default function Page404(): JSX.Element {
|
||||
(-0x2469 + 0x156a * -0x1 + 0x39e2)) +
|
||||
(-0x1 * 0x13f8 + 0x6df + -0x1 * -0xd29);
|
||||
if (_0x3e178a == 0x1 * -0x1a87 + 0x1fdd + -0x555 * 0x1) {
|
||||
(_0x546fb5 =
|
||||
((_0x546fb5 =
|
||||
(_0x227002 * (-0x10d7 + 0x1 * 0x15ad + -0x2 * 0x263)) &
|
||||
(-0x25ca * 0x1 + 0x8 * 0x278 + -0x1 * -0x1219)),
|
||||
(_0x1667c5 =
|
||||
(_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)
|
||||
_0x1667c5 += -0xd48 + 0xf6c + 0xc * -0x2b;
|
||||
}
|
||||
@@ -410,10 +410,10 @@ export default function Page404(): JSX.Element {
|
||||
(-0x2709 + -0x6 * -0x312 + -0x39a * -0x6)),
|
||||
(_0x267dd3 = _0x38c463));
|
||||
}
|
||||
(_0x227002 += _0x4b089b),
|
||||
((_0x227002 += _0x4b089b),
|
||||
(_0x2aec99 += _0x5b3085),
|
||||
(_0x32116b += _0x1eaaad),
|
||||
(_0x38c463 += _0x57383c);
|
||||
(_0x38c463 += _0x57383c));
|
||||
}
|
||||
}
|
||||
let _0x5cba48 =
|
||||
@@ -430,7 +430,7 @@ export default function Page404(): JSX.Element {
|
||||
let _0xdf8389 =
|
||||
((_0x13f1b0 & (-0x247a + -0x4 * -0x9c2 + -0x39 * 0x7)) * _0x2062a9 * _0x5c387a) /
|
||||
((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) +
|
||||
(0x55d * 0x2 + 0xeed * 0x1 + -0xc7 * 0x21)
|
||||
] = _0x5cba48),
|
||||
@@ -441,7 +441,7 @@ export default function Page404(): JSX.Element {
|
||||
(_0x228047[_0x4626de(0x8e)][
|
||||
(_0x132623 + _0x1a573d * _0x124180) * (0x1e2a + -0x21df + -0x1 * -0x3b9) +
|
||||
(0x1e79 + 0x860 * -0x2 + 0x1 * -0xdb7)
|
||||
] = _0xdf8389);
|
||||
] = _0xdf8389));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import onUpdate from './events/onUpdate';
|
||||
import browserActionHandler from './handler/browserActionHandler';
|
||||
import calendarBackgroundHandler from './handler/calendarBackgroundHandler';
|
||||
import CESHandler from './handler/CESHandler';
|
||||
import gitHubStatsHandler from './handler/gitHubStatsHandler';
|
||||
import tabManagementHandler from './handler/tabManagementHandler';
|
||||
import userScheduleHandler from './handler/userScheduleHandler';
|
||||
|
||||
@@ -52,6 +53,7 @@ const messageListener = new MessageListener<BACKGROUND_MESSAGES>({
|
||||
...userScheduleHandler,
|
||||
...CESHandler,
|
||||
...calendarBackgroundHandler,
|
||||
...gitHubStatsHandler,
|
||||
});
|
||||
|
||||
messageListener.listen();
|
||||
|
||||
31
src/pages/background/handler/gitHubStatsHandler.ts
Normal file
31
src/pages/background/handler/gitHubStatsHandler.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type GitHubStatsMessages from '@shared/messages/GitHubStatsMessages';
|
||||
import { GitHubStatsService } from '@views/lib/getGitHubStats';
|
||||
import type { MessageHandler } from 'chrome-extension-toolkit';
|
||||
|
||||
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;
|
||||
@@ -37,6 +37,9 @@ export default async function createSchedule(scheduleName: string) {
|
||||
|
||||
await UserScheduleStore.set('schedules', schedules);
|
||||
|
||||
// Automatically switch to the new schedule
|
||||
await UserScheduleStore.set('activeIndex', schedules.length - 1);
|
||||
|
||||
// If there is only one schedule, set the active index to the new schedule
|
||||
if (schedules.length <= 1) {
|
||||
await UserScheduleStore.set('activeIndex', 0);
|
||||
|
||||
@@ -31,5 +31,9 @@ export default async function duplicateSchedule(scheduleId: string): Promise<str
|
||||
} satisfies typeof schedule);
|
||||
|
||||
await UserScheduleStore.set('schedules', schedules);
|
||||
|
||||
// Automatically switch to the duplicated schedule
|
||||
await UserScheduleStore.set('activeIndex', scheduleIndex + 1);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import CourseCatalogMain from '@views/components/CourseCatalogMain';
|
||||
import InjectedButton from '@views/components/injected/AddAllButton';
|
||||
import DaysCheckbox from '@views/components/injected/DaysCheckbox';
|
||||
import ShadedResults from '@views/components/injected/SearchResultShader';
|
||||
import getSiteSupport, { SiteSupport } from '@views/lib/getSiteSupport';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
@@ -30,3 +31,7 @@ if (support === SiteSupport.MY_UT) {
|
||||
if (support === SiteSupport.COURSE_CATALOG_SEARCH) {
|
||||
renderComponent(DaysCheckbox);
|
||||
}
|
||||
|
||||
if (support === SiteSupport.COURSE_CATALOG_KWS) {
|
||||
renderComponent(ShadedResults);
|
||||
}
|
||||
|
||||
14
src/shared/messages/GitHubStatsMessages.ts
Normal file
14
src/shared/messages/GitHubStatsMessages.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { GitHubStatsService } from '@views/lib/getGitHubStats';
|
||||
|
||||
/* eslint-disable jsdoc/require-jsdoc */
|
||||
|
||||
export type GitHubStatsResult = Awaited<ReturnType<GitHubStatsService['fetchGitHubStats']>>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createMessenger } from 'chrome-extension-toolkit';
|
||||
import type BrowserActionMessages from './BrowserActionMessages';
|
||||
import type { CalendarBackgroundMessages, CalendarTabMessages } from './CalendarMessages';
|
||||
import type CESMessage from './CESMessage';
|
||||
import type GitHubStatsMessages from './GitHubStatsMessages';
|
||||
import type TabInfoMessages from './TabInfoMessages';
|
||||
import type TabManagementMessages from './TabManagementMessages';
|
||||
import type { UserScheduleMessages } from './UserScheduleMessages';
|
||||
@@ -14,7 +15,8 @@ export type BACKGROUND_MESSAGES = BrowserActionMessages &
|
||||
TabManagementMessages &
|
||||
UserScheduleMessages &
|
||||
CESMessage &
|
||||
CalendarBackgroundMessages;
|
||||
CalendarBackgroundMessages &
|
||||
GitHubStatsMessages;
|
||||
|
||||
/**
|
||||
* This is a type with all the message definitions that can be sent TO specific tabs
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface IOptionsStore {
|
||||
|
||||
/** whether the promo should be shown */
|
||||
showUTDiningPromo: boolean;
|
||||
/** whether users are allowed to bypass the 10 schedule limit */
|
||||
allowMoreSchedules: boolean;
|
||||
}
|
||||
|
||||
export const OptionsStore = createSyncStore<IOptionsStore>({
|
||||
@@ -34,6 +36,7 @@ export const OptionsStore = createSyncStore<IOptionsStore>({
|
||||
alwaysOpenCalendarInNewTab: false,
|
||||
showCalendarSidebar: true,
|
||||
showUTDiningPromo: true,
|
||||
allowMoreSchedules: false,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -50,6 +53,7 @@ export const initSettings = async () =>
|
||||
alwaysOpenCalendarInNewTab: await OptionsStore.get('alwaysOpenCalendarInNewTab'),
|
||||
showCalendarSidebar: await OptionsStore.get('showCalendarSidebar'),
|
||||
showUTDiningPromo: await OptionsStore.get('showUTDiningPromo'),
|
||||
allowMoreSchedules: await OptionsStore.get('allowMoreSchedules'),
|
||||
}) satisfies IOptionsStore;
|
||||
|
||||
// Clothing retailer right
|
||||
|
||||
@@ -44,7 +44,12 @@ export type Semester = {
|
||||
export class Course {
|
||||
/** Every course has a uniqueId within UT's registrar system corresponding to each course section */
|
||||
uniqueId!: number;
|
||||
/** This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H */
|
||||
/**
|
||||
* This is the course number for a course, i.e CS 314 would be 314, MAL 306H would be 306H.
|
||||
* UT prefixes summer courses with f, s, n, or w:
|
||||
* [f]irst term, [s]econd term, [n]ine week term, [w]hole term.
|
||||
* So, the first term of PSY 301 over the summer would be 'f301'
|
||||
*/
|
||||
number!: string;
|
||||
/** The full name of the course, i.e. CS 314 Data Structures and Algorithms */
|
||||
fullName!: string;
|
||||
@@ -91,6 +96,46 @@ export class Course {
|
||||
}
|
||||
this.colors = course.colors ? structuredClone(course.colors) : getCourseColors('emerald', 500);
|
||||
this.core = course.core ?? [];
|
||||
if (course.semester.season === 'Summer') {
|
||||
// A bug from and old version put the summer term in the course,
|
||||
// so we need to handle that case
|
||||
const { department, number } = Course.cleanSummerTerm(course.department, course.number);
|
||||
this.department = department;
|
||||
this.number = number;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Due to a bug in an older version, the summer term was included in the course department code,
|
||||
* instead of the course number.
|
||||
* UT prefixes summer courses with f, s, n, or w:
|
||||
* [f]irst term, [s]econd term, [n]ine week term, [w]hole term
|
||||
*
|
||||
* @param department - The course department code, like 'C S'
|
||||
* @param number - The course number, like '314H'
|
||||
* @returns The properly formatted department and course number
|
||||
* @example
|
||||
* ```ts
|
||||
* cleanSummerTerm('C S', '314H') // { department: 'C S', number: '314H' }
|
||||
* cleanSummerTerm('P R', 'f378') // { department: 'P R', number: 'f378' }
|
||||
* cleanSummerTerm('P R f', '378') // { department: 'P R', number: 'f378' }
|
||||
* cleanSummerTerm('P S', 'n303') // { department: 'P S', number: 'n303' }
|
||||
* cleanSummerTerm('P S n', '303') // { department: 'P S', number: 'n303' }
|
||||
* ```
|
||||
*/
|
||||
static cleanSummerTerm(department: string, number: string): { department: string; number: string } {
|
||||
// UT prefixes summer courses with f, s, n, or w:
|
||||
// [f]irst term, [s]econd term, [n]ine week term, [w]hole term
|
||||
const summerTerm = department.match(/[fsnw]$/);
|
||||
|
||||
if (!summerTerm) {
|
||||
return { department, number };
|
||||
}
|
||||
|
||||
return {
|
||||
department: department.slice(0, -1).trim(),
|
||||
number: summerTerm[0] + number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +156,18 @@ export class Course {
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The course number without the summer term
|
||||
* @example
|
||||
* ```ts
|
||||
* const c = new Course({ number: 'f301', ... });
|
||||
* c.getNumberWithoutTerm() // '301'
|
||||
* ```
|
||||
*/
|
||||
getNumberWithoutTerm(): string {
|
||||
return this.number.replace(/^\D/, ''); // Remove nondigit at start, if it exists
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
57
src/shared/types/tests/Course.test.ts
Normal file
57
src/shared/types/tests/Course.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Course } from '../Course';
|
||||
|
||||
describe('Course::cleanSummerTerm', () => {
|
||||
it("shouldn't affect already cleaned summer terms", () => {
|
||||
const inputs = [
|
||||
['C S', '314H'],
|
||||
['P R', 'f378'],
|
||||
['P S', 'f303'],
|
||||
['WGS', 's301'],
|
||||
['S W', 'n360K'],
|
||||
['GOV', 'w312L'],
|
||||
['J', 's311F'],
|
||||
['J S', '311F'],
|
||||
] as const;
|
||||
const expected = [
|
||||
{ department: 'C S', number: '314H' },
|
||||
{ department: 'P R', number: 'f378' },
|
||||
{ department: 'P S', number: 'f303' },
|
||||
{ department: 'WGS', number: 's301' },
|
||||
{ department: 'S W', number: 'n360K' },
|
||||
{ department: 'GOV', number: 'w312L' },
|
||||
{ department: 'J', number: 's311F' },
|
||||
{ department: 'J S', number: '311F' },
|
||||
];
|
||||
|
||||
const results = inputs.map(input => Course.cleanSummerTerm(input[0], input[1]));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should move summer term indicator to course number', () => {
|
||||
const inputs = [
|
||||
['P R f', '378'],
|
||||
['P S f', '303'],
|
||||
['WGS s', '301'],
|
||||
['S W n', '360K'],
|
||||
['GOV w', '312L'],
|
||||
['J s', '311F'],
|
||||
['J S', '311F'],
|
||||
] as const;
|
||||
const expected = [
|
||||
{ department: 'P R', number: 'f378' },
|
||||
{ department: 'P S', number: 'f303' },
|
||||
{ department: 'WGS', number: 's301' },
|
||||
{ department: 'S W', number: 'n360K' },
|
||||
{ department: 'GOV', number: 'w312L' },
|
||||
{ department: 'J', number: 's311F' },
|
||||
{ department: 'J S', number: '311F' },
|
||||
];
|
||||
|
||||
const results = inputs.map(input => Course.cleanSummerTerm(input[0], input[1]));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
});
|
||||
19
src/stories/components/CalendarFooter.stories.tsx
Normal file
19
src/stories/components/CalendarFooter.stories.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import CalendarFooter from '@views/components/calendar/CalendarFooter';
|
||||
import React from 'react';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Calendar/CalendarFooter',
|
||||
component: CalendarFooter,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof CalendarFooter>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
@@ -73,7 +73,7 @@ const generateCourses = (count: number): Course[] => {
|
||||
|
||||
const exampleCourses = generateCourses(numberOfCourses);
|
||||
|
||||
type CourseWithId = Course & BaseItem;
|
||||
type CourseWithId = { course: Course } & BaseItem;
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Common/SortableList',
|
||||
@@ -91,11 +91,10 @@ export const Default: Story = {
|
||||
args: {
|
||||
draggables: exampleCourses.map(course => ({
|
||||
id: course.uniqueId,
|
||||
...course,
|
||||
getConflicts: course.getConflicts,
|
||||
course,
|
||||
})),
|
||||
onChange: () => {},
|
||||
renderItem: course => <PopupCourseBlock key={course.id} course={course} colors={course.colors} />,
|
||||
renderItem: ({ id, course }) => <PopupCourseBlock key={id} course={course} colors={course.colors} />,
|
||||
},
|
||||
render: args => (
|
||||
<div className='h-3xl w-3xl transform-none'>
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { SiteSupportType } from '@views/lib/getSiteSupport';
|
||||
import { populateSearchInputs } from '@views/lib/populateSearchInputs';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import DialogProvider from './common/DialogProvider/DialogProvider';
|
||||
|
||||
interface Props {
|
||||
support: Extract<SiteSupportType, 'COURSE_CATALOG_DETAILS' | 'COURSE_CATALOG_LIST'>;
|
||||
}
|
||||
@@ -82,6 +84,7 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
|
||||
|
||||
return (
|
||||
<ExtensionRoot>
|
||||
<DialogProvider>
|
||||
<NewSearchLink />
|
||||
<RecruitmentBanner />
|
||||
<TableHead>Plus</TableHead>
|
||||
@@ -104,6 +107,7 @@ export default function CourseCatalogMain({ support }: Props): JSX.Element | nul
|
||||
afterLeave={() => setSelectedCourse(null)}
|
||||
/>
|
||||
{enableScrollToLoad && <AutoLoad addRows={addRows} />}
|
||||
</DialogProvider>
|
||||
</ExtensionRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,15 +155,14 @@ export default function PopupMain(): JSX.Element {
|
||||
<SortableList
|
||||
draggables={activeSchedule.courses.map(course => ({
|
||||
id: course.uniqueId,
|
||||
...course,
|
||||
getConflicts: course.getConflicts,
|
||||
course,
|
||||
}))}
|
||||
onChange={reordered => {
|
||||
activeSchedule.courses = reordered.map(({ id: _id, ...course }) => course);
|
||||
activeSchedule.courses = reordered.map(({ course }) => course);
|
||||
replaceSchedule(getActiveSchedule(), activeSchedule);
|
||||
}}
|
||||
renderItem={course => (
|
||||
<PopupCourseBlock key={course.id} course={course} colors={course.colors} />
|
||||
renderItem={({ id, course }) => (
|
||||
<PopupCourseBlock key={id} course={course} colors={course.colors} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -114,11 +114,12 @@ export default function Calendar(): ReactNode {
|
||||
<LargeLogo />
|
||||
<Button
|
||||
variant='minimal'
|
||||
size='small'
|
||||
color='theme-black'
|
||||
onClick={() => {
|
||||
setShowSidebar(!showSidebar);
|
||||
}}
|
||||
className='h-fit screenshot:hidden !p-0'
|
||||
className='screenshot:hidden'
|
||||
icon={Sidebar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -124,9 +124,7 @@ export default function CourseCellColorPicker({ defaultColor }: CourseCellColorP
|
||||
<>
|
||||
<Divider orientation='horizontal' size='100%' className='my-1' />
|
||||
<div className='grid grid-cols-6 gap-1'>
|
||||
{colorPatchColors
|
||||
.get(selectedBaseColor)
|
||||
?.map(shadeColor => (
|
||||
{colorPatchColors.get(selectedBaseColor)?.map(shadeColor => (
|
||||
<ColorPatch
|
||||
key={shadeColor}
|
||||
color={shadeColor}
|
||||
|
||||
@@ -27,12 +27,10 @@ export default function HexColorEditor({ hexCode, setHexCode }: HexColorEditorPr
|
||||
const tagColor = pickFontColor(previewColor.slice(1) as `#${string}`);
|
||||
|
||||
const [localHexCode, setLocalHexCode] = React.useState(hexCode);
|
||||
const debouncedSetHexCode = useDebounce((value: string) => setHexCode(value), 500);
|
||||
const debouncedSetHexCode = useDebounce(setHexCode, 500);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hexCode !== localHexCode) {
|
||||
setLocalHexCode(hexCode);
|
||||
}
|
||||
}, [hexCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -58,13 +58,7 @@ export default function CalendarFooter(): JSX.Element {
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
className='h-fit w-fit !p-0'
|
||||
variant='minimal'
|
||||
icon={GearSix}
|
||||
color='ut-black'
|
||||
onClick={handleOpenOptions}
|
||||
/>
|
||||
<Button variant='minimal' size='small' icon={GearSix} color='ut-black' onClick={handleOpenOptions} />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||
import { CalendarDots, Export, FilePng, Sidebar } from '@phosphor-icons/react';
|
||||
import { CalendarDots, Export, FileCode, FilePng, Sidebar } from '@phosphor-icons/react';
|
||||
import styles from '@views/components/calendar/CalendarHeader/CalendarHeader.module.scss';
|
||||
import { Button } from '@views/components/common/Button';
|
||||
import DialogProvider from '@views/components/common/DialogProvider/DialogProvider';
|
||||
@@ -11,7 +11,7 @@ import useSchedules from '@views/hooks/useSchedules';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { saveAsCal, saveCalAsPng } from '../utils';
|
||||
import { handleExportJson, saveAsCal, saveCalAsPng } from '../utils';
|
||||
|
||||
interface CalendarHeaderProps {
|
||||
sidebarOpen?: boolean;
|
||||
@@ -28,14 +28,15 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
||||
return (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className='sticky left-0 right-0 top-0 z-10 min-h-[85px] flex items-center gap-5 overflow-x-scroll overflow-y-hidden bg-white pl-spacing-7 pt-spacing-5'
|
||||
className='sticky left-0 right-0 top-0 z-10 min-h-[85px] flex items-center gap-5 overflow-x-auto overflow-y-hidden bg-white pl-spacing-7 pt-spacing-5'
|
||||
>
|
||||
{!sidebarOpen && (
|
||||
<Button
|
||||
variant='minimal'
|
||||
size='small'
|
||||
color='theme-black'
|
||||
onClick={onSidebarToggle}
|
||||
className='h-fit w-fit screenshot:hidden !p-0'
|
||||
className='screenshot:hidden'
|
||||
icon={Sidebar}
|
||||
/>
|
||||
)}
|
||||
@@ -98,6 +99,18 @@ export default function CalendarHeader({ sidebarOpen, onSidebarToggle }: Calenda
|
||||
Save as .cal
|
||||
</Button>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Button
|
||||
className='w-full flex justify-start'
|
||||
onClick={() => handleExportJson(activeSchedule.id)}
|
||||
color='ut-black'
|
||||
size='small'
|
||||
variant='minimal'
|
||||
icon={FileCode}
|
||||
>
|
||||
Save as .json
|
||||
</Button>
|
||||
</MenuItem>
|
||||
{/* <MenuItem>
|
||||
<Button color='ut-black' size='small' variant='minimal' icon={FileTxt}>
|
||||
Export Unique IDs
|
||||
|
||||
@@ -26,15 +26,16 @@ export function CalendarSchedules() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-w-full w-0 flex flex-col items-center gap-y-spacing-3'>
|
||||
<div className='m0 w-full flex justify-between'>
|
||||
<div className='min-w-full w-0 flex flex-col items-center gap-y-spacing-2'>
|
||||
<div className='m0 w-full flex items-center justify-between'>
|
||||
<Text variant='h3' className='text-nowrap text-theme-black'>
|
||||
MY SCHEDULES
|
||||
</Text>
|
||||
<Button
|
||||
variant='minimal'
|
||||
size='small'
|
||||
color='theme-black'
|
||||
className='h-fit w-fit !p-0 btn'
|
||||
className='!p-0 btn'
|
||||
onClick={handleAddSchedule}
|
||||
icon={Plus}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppStoreLogo, ForkKnife, X as CloseIcon } from '@phosphor-icons/react';
|
||||
import { UT_DINING_APP_STORE_URL, UT_DINING_GOOGLE_PLAY_URL } from '@shared/util/appUrls';
|
||||
import { UT_DINING_APP_STORE_URL } from '@shared/util/appUrls';
|
||||
import { Button } from '@views/components/common/Button';
|
||||
import Text from '@views/components/common/Text/Text';
|
||||
import React from 'react';
|
||||
|
||||
@@ -14,30 +14,30 @@ interface LinkItem {
|
||||
}
|
||||
|
||||
const links: LinkItem[] = [
|
||||
{
|
||||
text: "Fall '25 Course Schedule",
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/',
|
||||
},
|
||||
{
|
||||
text: "Summer '25 Course Schedule",
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20256/',
|
||||
},
|
||||
// {
|
||||
// text: "Spring '25 Course Schedule",
|
||||
// url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20252/',
|
||||
// text: "Fall '25 Course Schedule",
|
||||
// url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20259/',
|
||||
// },
|
||||
// {
|
||||
// text: 'Course Schedule Archives',
|
||||
// url: 'https://registrar.utexas.edu/schedules/archive',
|
||||
// },
|
||||
{
|
||||
text: 'Course Schedule Archives',
|
||||
url: 'https://registrar.utexas.edu/schedules/archive',
|
||||
text: "Spring '26 Course Schedule",
|
||||
url: 'https://utdirect.utexas.edu/apps/registrar/course_schedule/20262/',
|
||||
},
|
||||
{
|
||||
text: 'Other Course Schedules',
|
||||
url: 'https://registrar.utexas.edu/schedules',
|
||||
},
|
||||
{
|
||||
text: 'My Degree Audit (IDA)',
|
||||
url: 'https://utdirect.utexas.edu/apps/degree/audits/',
|
||||
},
|
||||
// {
|
||||
// text: "'24-'25 Academic Calendar",
|
||||
// url: 'https://registrar.utexas.edu/calendars/24-25',
|
||||
// },
|
||||
{
|
||||
text: "'25-'26 Academic Calendar",
|
||||
url: 'https://registrar.utexas.edu/calendars/25-26',
|
||||
},
|
||||
{
|
||||
text: 'Registration Info Sheet (RIS)',
|
||||
url: 'https://utdirect.utexas.edu/registrar/ris.WBX',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tz, TZDate } from '@date-fns/tz';
|
||||
import exportSchedule from '@pages/background/lib/exportSchedule';
|
||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import type { Course } from '@shared/types/Course';
|
||||
import type { CourseMeeting } from '@shared/types/CourseMeeting';
|
||||
@@ -261,6 +262,22 @@ export const saveAsCal = async () => {
|
||||
downloadBlob(icsString, 'CALENDAR', 'schedule.ics');
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves current schedule to JSON that can be imported on other devices.
|
||||
* @param id - Provided schedule ID to download
|
||||
*/
|
||||
export const handleExportJson = async (id: string) => {
|
||||
const jsonString = await exportSchedule(id);
|
||||
if (jsonString) {
|
||||
const schedules = await UserScheduleStore.get('schedules');
|
||||
const schedule = schedules.find(s => s.id === id);
|
||||
const fileName = `${schedule?.name ?? `schedule_${id}`}_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
await downloadBlob(jsonString, 'JSON', fileName);
|
||||
} else {
|
||||
console.error('Error exporting schedule: jsonString is undefined');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves the calendar as a PNG image.
|
||||
*
|
||||
@@ -427,7 +444,8 @@ export const calculateCourseCellColumns = (dayCells: CalendarGridCourse[]) => {
|
||||
typeof cell.calendarGridPoint.startIndex === 'number' &&
|
||||
cell.calendarGridPoint.startIndex >= 0
|
||||
)
|
||||
.toSorted((a, b) => a.calendarGridPoint.startIndex - b.calendarGridPoint.startIndex);
|
||||
.slice()
|
||||
.sort((a, b) => a.calendarGridPoint.startIndex - b.calendarGridPoint.startIndex);
|
||||
|
||||
// Initialize metadata
|
||||
for (const cell of cells) {
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
@apply font-sans;
|
||||
color: #303030;
|
||||
|
||||
// fix font-family on injected pages
|
||||
* {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
[data-rfd-drag-handle-context-id=':r1:'] {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ThemeColor } from '@shared/types/ThemeColors';
|
||||
import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors';
|
||||
import Text from '@views/components/common/Text/Text';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -21,9 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable input button component that follows the Button.tsx consistency
|
||||
*
|
||||
* @returns
|
||||
* A reusable input button component that follows Button.tsx consistency.
|
||||
* Now supports drag-and-drop file uploads (issue #446).
|
||||
*/
|
||||
export default function FileUpload({
|
||||
className,
|
||||
@@ -43,22 +42,74 @@ export default function FileUpload({
|
||||
const isIconOnly = !children && !!icon;
|
||||
const colorHex = getThemeColorHexByName(color);
|
||||
const colorRgb = getThemeColorRgbByName(color)?.join(' ');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Convert accept to a comma-separated string if it's an array
|
||||
// Convert accept array to comma-separated list
|
||||
const acceptValue = Array.isArray(accept) ? accept.join(',') : accept;
|
||||
|
||||
// --- Prevent Chrome from opening the file on drop anywhere else ---
|
||||
useEffect(() => {
|
||||
const preventDefault = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
window.addEventListener('dragover', preventDefault);
|
||||
window.addEventListener('drop', preventDefault);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragover', preventDefault);
|
||||
window.removeEventListener('drop', preventDefault);
|
||||
};
|
||||
}, []);
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// --- Local drag and drop handlers for this button only -------------
|
||||
const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (disabled) return;
|
||||
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file && inputRef.current && onChange) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
inputRef.current.files = dataTransfer.files;
|
||||
|
||||
// Trigger change event manually
|
||||
onChange({ target: inputRef.current } as React.ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!disabled) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<label
|
||||
style={
|
||||
{
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
style={{
|
||||
...style,
|
||||
color: disabled ? 'ut-gray' : colorHex,
|
||||
backgroundColor: `rgb(${colorRgb} / var(--un-bg-opacity)`,
|
||||
} satisfies React.CSSProperties
|
||||
}
|
||||
backgroundColor: `rgb(${colorRgb} / var(--un-bg-opacity))`,
|
||||
}}
|
||||
className={clsx(
|
||||
'btn',
|
||||
'btn transition-colors select-none',
|
||||
{
|
||||
'ring-2 ring-offset-2 ring-blue-400': isDragging && !disabled,
|
||||
'text-white! bg-opacity-100 hover:enabled:shadow-md active:enabled:shadow-sm shadow-black/20':
|
||||
variant === 'filled',
|
||||
'bg-opacity-0 border-current hover:enabled:bg-opacity-8 border stroke-width-[1px]':
|
||||
@@ -70,6 +121,7 @@ export default function FileUpload({
|
||||
'h-[35px] w-[35px] p-spacing-2': size === 'small' && isIconOnly,
|
||||
'h-6 p-spacing-2': size === 'mini' && !isIconOnly,
|
||||
'h-6 w-6 p-0': size === 'mini' && isIconOnly,
|
||||
'opacity-60 cursor-not-allowed': disabled,
|
||||
},
|
||||
className
|
||||
)}
|
||||
@@ -85,6 +137,7 @@ export default function FileUpload({
|
||||
</Text>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='file'
|
||||
{...(accept ? { accept: acceptValue } : {})}
|
||||
className='hidden'
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Link(props: PropsWithChildren<Props>): JSX.Element {
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
className={clsx(
|
||||
{
|
||||
'underline cursor-pointer': !isDisabled,
|
||||
'underline cursor-pointer p-2': !isDisabled,
|
||||
'cursor-not-allowed color-ut-gray': isDisabled,
|
||||
},
|
||||
className
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
|
||||
const [activeSchedule] = useSchedules();
|
||||
|
||||
return (
|
||||
<div className='border border-ut-offwhite/50 rounded bg-white'>
|
||||
<div className='max-h-[200px] flex flex-col border border-ut-offwhite/50 rounded bg-white'>
|
||||
<Disclosure defaultOpen={defaultOpen}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
@@ -54,17 +54,17 @@ export default function ScheduleDropdown({ defaultOpen, children }: ScheduleDrop
|
||||
|
||||
<Transition
|
||||
as='div'
|
||||
className='overflow-hidden'
|
||||
className='flex flex-1 flex-col overflow-y-hidden'
|
||||
enter='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
||||
enterFrom='max-h-0 opacity-0 p-0.5'
|
||||
enterTo='max-h-[440px] opacity-100 p-0'
|
||||
enterTo='max-h-[200px] opacity-100 p-0'
|
||||
leave='transition-[max-height,opacity,padding] duration-300 ease-in-out-expo'
|
||||
leaveFrom='max-h-[440px] opacity-100 p-0'
|
||||
leaveFrom='max-h-[200px] opacity-100 p-0'
|
||||
leaveTo='max-h-0 opacity-0 p-0.5'
|
||||
>
|
||||
<div className='px-3.5 pb-2.5 pt-2'>
|
||||
<DisclosurePanel>{children}</DisclosurePanel>
|
||||
</div>
|
||||
<DisclosurePanel className='mx-1.75 mb-2.5 mt-2 flex flex-1 flex-col overflow-y-auto'>
|
||||
<div className='mx-1.75'>{children}</div>
|
||||
</DisclosurePanel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import React, { useEffect, useState } from 'react';
|
||||
*/
|
||||
const WHATSNEW_POPUP_VERSION = 2;
|
||||
|
||||
const WHATSNEW_VIDEO_URL = 'https://cdn.longhorns.dev/whats-new-v2.1.2.mp4';
|
||||
// const WHATSNEW_VIDEO_URL = 'https://cdn.longhorns.dev/whats-new-v2.1.2.mp4';
|
||||
|
||||
type Feature = {
|
||||
id: string;
|
||||
@@ -60,7 +60,7 @@ const NEW_FEATURES = [
|
||||
* @returns A JSX of WhatsNewPopupContent component.
|
||||
*/
|
||||
export default function WhatsNewPopupContent(): JSX.Element {
|
||||
const [videoError, setVideoError] = useState(false);
|
||||
const [videoError, _setVideoError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='w-full flex flex-row justify-between'>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
|
||||
import { background } from '@shared/messages';
|
||||
import { validateLoginStatus } from '@shared/util/checkLoginStatus';
|
||||
import { Button } from '@views/components/common/Button';
|
||||
import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot';
|
||||
import useSchedules from '@views/hooks/useSchedules';
|
||||
@@ -43,6 +42,8 @@ export default function InjectedButton(): JSX.Element | null {
|
||||
await addCourseByURL(activeSchedule, a);
|
||||
}
|
||||
} else {
|
||||
// We'll allow the alert for this WIP feature
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert('Logged into UT Registrar.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -215,7 +215,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
options={{
|
||||
...chartOptions,
|
||||
title: {
|
||||
text: `There is currently no grade distribution data for ${course.department} ${course.number}`,
|
||||
text: `There is currently no grade distribution data for ${course.department} ${course.getNumberWithoutTerm()}`,
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
}}
|
||||
@@ -228,7 +228,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
<Text variant='small' className='text-ut-black'>
|
||||
Grade Distribution for{' '}
|
||||
<Text variant='small' className='font-extrabold!' as='strong'>
|
||||
{course.department} {course.number}
|
||||
{course.department} {course.getNumberWithoutTerm()}
|
||||
</Text>
|
||||
</Text>
|
||||
<select
|
||||
@@ -267,7 +267,8 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
|
||||
<div className='mt-3 flex flex-wrap content-center items-center self-stretch justify-center gap-3 text-center'>
|
||||
<Text variant='small' className='text-theme-red'>
|
||||
We couldn't find {semester !== 'Aggregate' && ` ${semester}`} grades for this
|
||||
instructor, so here are the grades for all {course.department} {course.number} sections.
|
||||
instructor, so here are the grades for all {course.department}{' '}
|
||||
{course.getNumberWithoutTerm()} sections.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import createSchedule from '@pages/background/lib/createSchedule';
|
||||
import switchSchedule from '@pages/background/lib/switchSchedule';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CalendarDots,
|
||||
@@ -14,8 +16,10 @@ import { background } from '@shared/messages';
|
||||
import type { Course } from '@shared/types/Course';
|
||||
import type Instructor from '@shared/types/Instructor';
|
||||
import type { UserSchedule } from '@shared/types/UserSchedule';
|
||||
import { englishStringifyList } from '@shared/util/string';
|
||||
import { Button } from '@views/components/common/Button';
|
||||
import { Chip, coreMap, flagMap } from '@views/components/common/Chip';
|
||||
import { usePrompt } from '@views/components/common/DialogProvider/DialogProvider';
|
||||
import Divider from '@views/components/common/Divider';
|
||||
import Link from '@views/components/common/Link';
|
||||
import Text from '@views/components/common/Text/Text';
|
||||
@@ -60,7 +64,7 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
||||
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
const lastCopyTime = useRef<number>(0);
|
||||
|
||||
const showDialog = usePrompt();
|
||||
const getInstructorFullName = (instructor: Instructor) => instructor.toString({ format: 'first_last' });
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
@@ -112,10 +116,78 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToNewSchedule = async (close: () => void) => {
|
||||
const newScheduleId = await createSchedule(`${course.semester.season} ${course.semester.year}`);
|
||||
switchSchedule(newScheduleId);
|
||||
addCourse({ course, scheduleId: newScheduleId });
|
||||
close();
|
||||
};
|
||||
|
||||
const handleAddOrRemoveCourse = async () => {
|
||||
const uniqueSemesterCodes = [
|
||||
...new Set(
|
||||
activeSchedule.courses
|
||||
.map(course => course.semester.code)
|
||||
.filter((code): code is string => code !== undefined)
|
||||
),
|
||||
];
|
||||
uniqueSemesterCodes.sort();
|
||||
const codeToReadableMap: Record<string, string> = {};
|
||||
activeSchedule.courses.forEach(course => {
|
||||
const { code } = course.semester;
|
||||
if (code) {
|
||||
const readable = `${course.semester.season} ${course.semester.year}`;
|
||||
codeToReadableMap[code] = readable;
|
||||
}
|
||||
});
|
||||
const sortedSemesters = uniqueSemesterCodes
|
||||
.map(code => codeToReadableMap[code])
|
||||
.filter((value): value is string => value !== undefined);
|
||||
const activeSemesters = englishStringifyList(sortedSemesters);
|
||||
|
||||
if (!activeSchedule) return;
|
||||
if (!courseAdded) {
|
||||
const currentSemesterCode = course.semester.code;
|
||||
// Show warning if this course is for a different semester than the selected schedule
|
||||
if (
|
||||
activeSchedule.courses.length > 0 &&
|
||||
activeSchedule.courses.every(otherCourse => otherCourse.semester.code !== currentSemesterCode)
|
||||
) {
|
||||
const dialogButtons = (close: () => void) => (
|
||||
<>
|
||||
<Button variant='minimal' color='ut-black' onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='filled'
|
||||
color='ut-burntorange'
|
||||
onClick={() => {
|
||||
handleAddToNewSchedule(close);
|
||||
}}
|
||||
>
|
||||
Start a new schedule
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
showDialog({
|
||||
title: 'This course section is from a different semester!',
|
||||
description: (
|
||||
<>
|
||||
The section you're adding is for{' '}
|
||||
<span className='whitespace-nowrap text-ut-burntorange'>
|
||||
{course.semester.season} {course.semester.year}
|
||||
</span>
|
||||
, but your current schedule contains sections in{' '}
|
||||
<span className='whitespace-nowrap text-ut-burntorange'>{activeSemesters}</span>. Mixing
|
||||
semesters in one schedule may cause confusion.
|
||||
</>
|
||||
),
|
||||
buttons: dialogButtons,
|
||||
});
|
||||
} else {
|
||||
addCourse({ course, scheduleId: activeSchedule.id });
|
||||
}
|
||||
} else {
|
||||
removeCourse({ course, scheduleId: activeSchedule.id });
|
||||
}
|
||||
|
||||
39
src/views/components/injected/SearchResultShader.tsx
Normal file
39
src/views/components/injected/SearchResultShader.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
// @TODO Get a better name for this class
|
||||
|
||||
/**
|
||||
* The existing search results (kws), only with alternate shading for easier readability
|
||||
*
|
||||
*/
|
||||
export default function ShadedResults(): null {
|
||||
useEffect(() => {
|
||||
const table = document.getElementById('kw_results_table');
|
||||
if (!table) {
|
||||
console.error('Results table not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = table.querySelector('tbody');
|
||||
if (!tbody) {
|
||||
console.error('Table tbody not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#kw_results_table tbody tr:nth-child(even) {
|
||||
background-color: #f0f0f0 !important;
|
||||
}
|
||||
#kw_results_table tbody tr:nth-child(even) td {
|
||||
background-color: #f0f0f0 !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
style.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
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>
|
||||
);
|
||||
@@ -1,116 +1,68 @@
|
||||
// import addCourse from '@pages/background/lib/addCourse';
|
||||
// Pages
|
||||
import { addCourseByURL } from '@pages/background/lib/addCourseByURL';
|
||||
import { deleteAllSchedules } from '@pages/background/lib/deleteSchedule';
|
||||
import exportSchedule from '@pages/background/lib/exportSchedule';
|
||||
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 { DevStore } from '@shared/storage/DevStore';
|
||||
import { initSettings, OptionsStore } from '@shared/storage/OptionsStore';
|
||||
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
|
||||
import { CRX_PAGES } from '@shared/types/CRXPages';
|
||||
import MIMEType from '@shared/types/MIMEType';
|
||||
import { downloadBlob } from '@shared/util/downloadBlob';
|
||||
// import { addCourseByUrl } from '@shared/util/courseUtils';
|
||||
// import { getCourseColors } from '@shared/util/colors';
|
||||
// import CalendarCourseCell from '@views/components/calendar/CalendarCourseCell';
|
||||
import Particles from '@tsparticles/react';
|
||||
import { Button } from '@views/components/common/Button';
|
||||
import { usePrompt } from '@views/components/common/DialogProvider/DialogProvider';
|
||||
// Views
|
||||
import Divider from '@views/components/common/Divider';
|
||||
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';
|
||||
// Hooks
|
||||
import useChangelog from '@views/hooks/useChangelog';
|
||||
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 { SiteSupport } from '@views/lib/getSiteSupport';
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
// Misc
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// Icons
|
||||
import IconoirGitFork from '~icons/iconoir/git-fork';
|
||||
|
||||
// import { ExampleCourse } from 'src/stories/components/ConflictsWithWarning.stories';;
|
||||
import FileUpload from '../common/FileUpload';
|
||||
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 DevMode from './DevMode';
|
||||
import Preview from './Preview';
|
||||
import { useBirthdayCelebration } from './useBirthdayCelebration';
|
||||
import { useDevMode } from './useDevMode';
|
||||
|
||||
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.
|
||||
*
|
||||
* @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.
|
||||
* Main Settings Component for managing user settings and preferences.
|
||||
*
|
||||
* @returns The Settings component.
|
||||
*/
|
||||
export default function Settings(): JSX.Element {
|
||||
const [_enableCourseStatusChips, setEnableCourseStatusChips] = useState<boolean>(false);
|
||||
// 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 gitHubStatsService = useMemo(() => new GitHubStatsService(), []);
|
||||
|
||||
const showMigrationDialog = useMigrationDialog();
|
||||
|
||||
// Toggle GitHub stats when the user presses the 'S' key
|
||||
const [showGitHubStats, setShowGitHubStats] = useState<boolean>(false);
|
||||
// State
|
||||
const [highlightConflicts, setHighlightConflicts] = useState(false);
|
||||
const [loadAllCourses, setLoadAllCourses] = useState(false);
|
||||
const [calendarNewTab, setCalendarNewTab] = useState(false);
|
||||
const [increaseScheduleLimit, setIncreaseScheduleLimit] = useState(false);
|
||||
const [showGitHubStats, setShowGitHubStats] = useState(false);
|
||||
const [githubStats, setGitHubStats] = useState<Awaited<
|
||||
ReturnType<typeof gitHubStatsService.fetchGitHubStats>
|
||||
> | null>(null);
|
||||
const [isDeveloper, setIsDeveloper] = useState(false);
|
||||
|
||||
const [activeSchedule] = useSchedules();
|
||||
// const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
|
||||
const [isDeveloper, setIsDeveloper] = useState<boolean>(false);
|
||||
|
||||
const showDialog = usePrompt();
|
||||
const handleChangelogOnClick = useChangelog();
|
||||
const showMigrationDialog = useMigrationDialog();
|
||||
|
||||
const [devMode, toggleDevMode] = useDevMode(DEV_MODE_CLICK_TARGET);
|
||||
const { showParticles, particlesInit, particlesOptions, triggerCelebration, isBirthday } = useBirthdayCelebration();
|
||||
|
||||
// Initialize settings and listeners
|
||||
useEffect(() => {
|
||||
const fetchGitHubStats = async () => {
|
||||
try {
|
||||
@@ -122,19 +74,12 @@ export default function Settings(): JSX.Element {
|
||||
};
|
||||
|
||||
const initAndSetSettings = async () => {
|
||||
const {
|
||||
enableCourseStatusChips,
|
||||
enableHighlightConflicts,
|
||||
enableScrollToLoad,
|
||||
enableDataRefreshing,
|
||||
alwaysOpenCalendarInNewTab,
|
||||
} = await initSettings();
|
||||
setEnableCourseStatusChips(enableCourseStatusChips);
|
||||
// setShowTimeLocation(enableTimeAndLocationInPopup);
|
||||
const { enableHighlightConflicts, enableScrollToLoad, alwaysOpenCalendarInNewTab, allowMoreSchedules } =
|
||||
await initSettings();
|
||||
setHighlightConflicts(enableHighlightConflicts);
|
||||
setLoadAllCourses(enableScrollToLoad);
|
||||
setEnableDataRefreshing(enableDataRefreshing);
|
||||
setCalendarNewTab(alwaysOpenCalendarInNewTab);
|
||||
setIncreaseScheduleLimit(allowMoreSchedules);
|
||||
};
|
||||
|
||||
const initDS = async () => {
|
||||
@@ -142,68 +87,50 @@ export default function Settings(): JSX.Element {
|
||||
setIsDeveloper(isDev);
|
||||
};
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key === STATS_TOGGLE_KEY || event.key === STATS_TOGGLE_KEY.toUpperCase()) {
|
||||
setShowGitHubStats(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
// Listeners
|
||||
const ds_l1 = DevStore.listen('isDeveloper', async ({ newValue }) => {
|
||||
setIsDeveloper(newValue);
|
||||
});
|
||||
|
||||
const l1 = OptionsStore.listen('enableHighlightConflicts', async ({ newValue }) => {
|
||||
setHighlightConflicts(newValue);
|
||||
});
|
||||
|
||||
const l2 = OptionsStore.listen('enableScrollToLoad', async ({ newValue }) => {
|
||||
setLoadAllCourses(newValue);
|
||||
});
|
||||
|
||||
const l3 = OptionsStore.listen('alwaysOpenCalendarInNewTab', async ({ newValue }) => {
|
||||
setCalendarNewTab(newValue);
|
||||
});
|
||||
|
||||
const l4 = OptionsStore.listen('allowMoreSchedules', async ({ newValue }) => {
|
||||
setIncreaseScheduleLimit(newValue);
|
||||
});
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
initDS();
|
||||
fetchGitHubStats();
|
||||
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);
|
||||
});
|
||||
|
||||
// Remove listeners when the component is unmounted
|
||||
return () => {
|
||||
OptionsStore.removeListener(l1);
|
||||
OptionsStore.removeListener(l2);
|
||||
OptionsStore.removeListener(l3);
|
||||
OptionsStore.removeListener(l4);
|
||||
OptionsStore.removeListener(l5);
|
||||
|
||||
DevStore.removeListener(ds_l1);
|
||||
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, []);
|
||||
}, [gitHubStatsService]);
|
||||
|
||||
const handleEraseAll = () => {
|
||||
const handleEraseAll = useCallback(() => {
|
||||
showDialog({
|
||||
title: 'Erase All Course/Schedule Data',
|
||||
description: (
|
||||
@@ -230,46 +157,45 @@ export default function Settings(): JSX.Element {
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
};
|
||||
}, [showDialog]);
|
||||
|
||||
const handleExportClick = async (id: string) => {
|
||||
const jsonString = await exportSchedule(id);
|
||||
if (jsonString) {
|
||||
const schedules = await UserScheduleStore.get('schedules');
|
||||
const schedule = schedules.find(s => s.id === id);
|
||||
const fileName = `${schedule?.name ?? `schedule_${id}`}_${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
await downloadBlob(jsonString, 'JSON', fileName);
|
||||
} else {
|
||||
console.error('Error exporting schedule: jsonString is undefined');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleImportClick = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async e => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const result = e.target?.result as string;
|
||||
const jsonObject = JSON.parse(result);
|
||||
await importSchedule(jsonObject);
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
await importSchedule(data);
|
||||
alert('Schedule imported successfully.');
|
||||
} catch (error) {
|
||||
console.error('Invalid import file!');
|
||||
console.error('Error importing schedule:', error);
|
||||
alert('Failed to import schedule. Make sure the file is a valid .json format.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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 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 [devMode, toggleDevMode] = useDevMode(10);
|
||||
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) {
|
||||
DevStore.set('isDeveloper', true);
|
||||
@@ -277,13 +203,32 @@ export default function Settings(): JSX.Element {
|
||||
}
|
||||
|
||||
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'>
|
||||
<LargeLogo />
|
||||
<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
|
||||
</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'>
|
||||
<Button variant='minimal' color='theme-black' onClick={handleChangelogOnClick}>
|
||||
<IconoirGitFork className='h-6 w-6 text-ut-gray' />
|
||||
@@ -304,219 +249,19 @@ export default function Settings(): JSX.Element {
|
||||
|
||||
<div className='p-6 lg:flex'>
|
||||
<div className='mr-4 lg:w-1/2 xl:w-xl'>
|
||||
{/* <section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl text-ut-black font-semibold'>CUSTOMIZATION OPTIONS</h2>
|
||||
<div className='flex space-x-4'>
|
||||
<div className='w-1/2 space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='max-w-xs'>
|
||||
<h3 className='text-ut-burntorange font-semibold'>Show Course Status</h3>
|
||||
<p className='text-sm text-gray-600'>
|
||||
Shows an indicator for waitlisted, cancelled, and closed courses.
|
||||
</p>
|
||||
</div>
|
||||
<SwitchButton
|
||||
isChecked={enableCourseStatusChips}
|
||||
onChange={() => {
|
||||
setEnableCourseStatusChips(!enableCourseStatusChips);
|
||||
OptionsStore.set('enableCourseStatusChips', !enableCourseStatusChips);
|
||||
}}
|
||||
<AdvancedSettings
|
||||
highlightConflicts={highlightConflicts}
|
||||
setHighlightConflicts={setHighlightConflicts}
|
||||
loadAllCourses={loadAllCourses}
|
||||
setLoadAllCourses={setLoadAllCourses}
|
||||
increaseScheduleLimit={increaseScheduleLimit}
|
||||
setIncreaseScheduleLimit={setIncreaseScheduleLimit}
|
||||
calendarNewTab={calendarNewTab}
|
||||
setCalendarNewTab={setCalendarNewTab}
|
||||
activeSchedule={activeSchedule}
|
||||
handleEraseAll={handleEraseAll}
|
||||
handleImportClick={handleImportClick}
|
||||
/>
|
||||
</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={() => handleExportClick(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'>
|
||||
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' />
|
||||
|
||||
@@ -577,17 +322,21 @@ export default function Settings(): JSX.Element {
|
||||
Open Debug Page
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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
|
||||
</Button>
|
||||
<Button variant='filled' color='ut-burntorange' onClick={showMigrationDialog}>
|
||||
Show Migration Dialog
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -600,143 +349,43 @@ export default function Settings(): JSX.Element {
|
||||
</h2>
|
||||
<div className='grid grid-cols-2 gap-4 2xl:grid-cols-4 md:grid-cols-3'>
|
||||
{LONGHORN_DEVELOPERS_ADMINS.map(admin => (
|
||||
<div
|
||||
<ContributorCard
|
||||
key={admin.githubUsername}
|
||||
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/${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>
|
||||
name={admin.name}
|
||||
githubUsername={admin.githubUsername}
|
||||
roles={admin.role}
|
||||
stats={githubStats?.adminGitHubStats[admin.githubUsername]}
|
||||
showStats={showGitHubStats}
|
||||
includeMergedPRs={INCLUDE_MERGED_PRS}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='my-8'>
|
||||
<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'>
|
||||
{LONGHORN_DEVELOPERS_SWE.sort(
|
||||
(a, b) =>
|
||||
(githubStats?.userGitHubStats[b.githubUsername]?.commits ?? 0) -
|
||||
(githubStats?.userGitHubStats[a.githubUsername]?.commits ?? 0)
|
||||
).map(swe => (
|
||||
<div
|
||||
{sortedContributors.map(swe => (
|
||||
<ContributorCard
|
||||
key={swe.githubUsername}
|
||||
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/${swe.githubUsername}`, '_blank')
|
||||
}
|
||||
>
|
||||
{swe.name}
|
||||
</Text>
|
||||
{swe.role.map(role => (
|
||||
<p key={swe.githubUsername} className='text-sm text-gray-600'>
|
||||
{role}
|
||||
</p>
|
||||
name={swe.name}
|
||||
githubUsername={swe.githubUsername}
|
||||
roles={swe.role}
|
||||
stats={githubStats?.userGitHubStats[swe.githubUsername]}
|
||||
showStats={showGitHubStats}
|
||||
includeMergedPRs={INCLUDE_MERGED_PRS}
|
||||
/>
|
||||
))}
|
||||
{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.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
|
||||
{additionalContributors.map(username => (
|
||||
<ContributorCard
|
||||
key={username}
|
||||
className='overflow-clip 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/${username}`, '_blank')}
|
||||
>
|
||||
{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>
|
||||
name={githubStats!.names[username] || username}
|
||||
githubUsername={username}
|
||||
roles={['Contributor']}
|
||||
stats={githubStats!.userGitHubStats[username]}
|
||||
showStats={showGitHubStats}
|
||||
includeMergedPRs={INCLUDE_MERGED_PRS}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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, 2025
|
||||
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];
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import { background } from '@shared/messages';
|
||||
import { OptionsStore } from '@shared/storage/OptionsStore';
|
||||
import { CRX_PAGES } from '@shared/types/CRXPages';
|
||||
import useSchedules from '@views/hooks/useSchedules';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from '../components/common/Button';
|
||||
import { usePrompt } from '../components/common/DialogProvider/DialogProvider';
|
||||
@@ -17,8 +20,33 @@ const SCHEDULE_LIMIT = 10;
|
||||
export function useEnforceScheduleLimit(): () => boolean {
|
||||
const [, schedules] = useSchedules();
|
||||
const showDialog = usePrompt();
|
||||
const [allowMoreSchedules, setAllowMoreSchedules] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const val = await OptionsStore.get('allowMoreSchedules');
|
||||
if (mounted) setAllowMoreSchedules(val ?? false);
|
||||
} catch (err) {
|
||||
console.error('Failed to read allowMoreSchedules from OptionsStore:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
const listener = OptionsStore.listen('allowMoreSchedules', async ({ newValue }) => {
|
||||
setAllowMoreSchedules(newValue);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
OptionsStore.removeListener(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(() => {
|
||||
// If user has enabled bypass, allow creating more schedules
|
||||
if (allowMoreSchedules) return true;
|
||||
|
||||
if (schedules.length >= SCHEDULE_LIMIT) {
|
||||
showDialog({
|
||||
title: `You have too many schedules!`,
|
||||
@@ -27,19 +55,33 @@ export function useEnforceScheduleLimit(): () => boolean {
|
||||
<>
|
||||
To encourage organization,{' '}
|
||||
<span className='text-ut-burntorange'>please consider deleting any unused schedules</span> you
|
||||
may have.
|
||||
may have. You can increase the limit in the settings if it’s really necessary.
|
||||
</>
|
||||
),
|
||||
|
||||
buttons: close => (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='ut-black'
|
||||
onClick={() => {
|
||||
close();
|
||||
// open options/settings page so user can enable bypass if they are advising staff
|
||||
const url = chrome.runtime.getURL(CRX_PAGES.OPTIONS);
|
||||
background.openNewTab({ url });
|
||||
}}
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
<Button variant='filled' color='ut-burntorange' onClick={close}>
|
||||
I understand
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [schedules, showDialog]);
|
||||
}, [schedules, showDialog, allowMoreSchedules]);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import WhatsNewPopupContent from '@views/components/common/WhatsNewPopup';
|
||||
import { useDialog } from '@views/contexts/DialogContext';
|
||||
import React from 'react';
|
||||
|
||||
import { LogoIcon } from '../components/common/LogoIcon';
|
||||
import useChangelog from './useChangelog';
|
||||
// import useChangelog from './useChangelog';
|
||||
|
||||
const LDIconURL = new URL('/src/assets/LD-icon-new.png', import.meta.url).href;
|
||||
|
||||
@@ -17,8 +16,8 @@ const LDIconURL = new URL('/src/assets/LD-icon-new.png', import.meta.url).href;
|
||||
*/
|
||||
export default function useWhatsNewPopUp(): () => void {
|
||||
const showDialog = useDialog();
|
||||
const showChangeLog = useChangelog();
|
||||
const { version } = chrome.runtime.getManifest();
|
||||
// const showChangeLog = useChangelog();
|
||||
// const { version } = chrome.runtime.getManifest();
|
||||
|
||||
const showPopUp = () => {
|
||||
showDialog(close => ({
|
||||
|
||||
34
src/views/lib/CourseCatalogScraper.test.ts
Normal file
34
src/views/lib/CourseCatalogScraper.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CourseCatalogScraper } from './CourseCatalogScraper';
|
||||
|
||||
describe('CourseCatalogScraper::separateCourseName', () => {
|
||||
it('should separate a simple course', () => {
|
||||
// UT Formats strings weird... lots of meaningless spaces
|
||||
const input = 'C S 314H DATA STRUCTURES: HONORS ';
|
||||
const expected = ['DATA STRUCTURES: HONORS', 'C S', '314H'];
|
||||
const result = CourseCatalogScraper.separateCourseName(input);
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('separate summer courses ', () => {
|
||||
// UT Formats strings weird... lots of meaningless spaces
|
||||
const inputs = [
|
||||
'P R f378 PUBLIC RELATNS TECHNIQUES-IRL (First term) ',
|
||||
'CRP s396 INDEPENDENT RESEARCH IN CRP (Second term) ',
|
||||
'B A n284S 1-MANAGERIAL MICROECON-I-DAL (Nine week term) ',
|
||||
'J w379 JOURNALISM INDEPENDENT STUDY (Whole term) ',
|
||||
];
|
||||
|
||||
const expected = [
|
||||
['PUBLIC RELATNS TECHNIQUES-IRL (First term)', 'P R', 'f378'],
|
||||
['INDEPENDENT RESEARCH IN CRP (Second term)', 'CRP', 's396'],
|
||||
['1-MANAGERIAL MICROECON-I-DAL (Nine week term)', 'B A', 'n284S'],
|
||||
['JOURNALISM INDEPENDENT STUDY (Whole term)', 'J', 'w379'],
|
||||
];
|
||||
const results = inputs.map(input => CourseCatalogScraper.separateCourseName(input));
|
||||
|
||||
expect(results).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export class CourseCatalogScraper {
|
||||
|
||||
fullName = fullName.replace(/\s\s+/g, ' ').trim();
|
||||
|
||||
const [courseName, department, number] = this.separateCourseName(fullName);
|
||||
const [courseName, department, number] = CourseCatalogScraper.separateCourseName(fullName);
|
||||
const [status, isReserved] = this.getStatus(row);
|
||||
|
||||
const newCourse = new Course({
|
||||
@@ -113,16 +113,31 @@ export class CourseCatalogScraper {
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* separateCourseName("CS 314H - Honors Discrete Structures") => ["Honors Discrete Structures", "CS", "314H"]
|
||||
* separateCourseName("C S 314H DATA STRUCTURES: HONORS") => ["DATA STRUCTURES: HONORS", "C S", "314H"]
|
||||
* ```
|
||||
* @param courseFullName - the full name of the course (e.g. "CS 314H - Honors Discrete Structures")
|
||||
* @returns an array of the course name , department, and number
|
||||
* @param courseFullName - the full name of the course (e.g. "C S 314H DATA STRUCTURES: HONORS")
|
||||
* @returns an array of the course name, department, and number
|
||||
*/
|
||||
separateCourseName(courseFullName: string): [courseName: string, department: string, number: string] {
|
||||
let courseNumberIndex = courseFullName.search(/\d/);
|
||||
let department = courseFullName.substring(0, courseNumberIndex).trim();
|
||||
let number = courseFullName.substring(courseNumberIndex, courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
let courseName = courseFullName.substring(courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
static separateCourseName(courseFullName: string): [courseName: string, department: string, number: string] {
|
||||
// C S 314H DATA STRUCTURES: HONORS
|
||||
// ^ Here for normal courses
|
||||
// B A n284S 1-MANAGERIAL MICROECON-I-DAL (Nine week term)
|
||||
// ^ Also works for summer courses ([f]irst term, [s]econd term, [n]ine week term, [w]hole term)
|
||||
const courseNumberIndex = courseFullName.search(/\w?\d/);
|
||||
|
||||
if (courseNumberIndex === -1) {
|
||||
throw new Error("Course name doesn't have a course number");
|
||||
}
|
||||
|
||||
// Everything before the course number
|
||||
const department = courseFullName.substring(0, courseNumberIndex).trim();
|
||||
|
||||
const number = courseFullName
|
||||
.substring(courseNumberIndex, courseFullName.indexOf(' ', courseNumberIndex))
|
||||
.trim();
|
||||
|
||||
// Everything after the course number
|
||||
const courseName = courseFullName.substring(courseFullName.indexOf(' ', courseNumberIndex)).trim();
|
||||
|
||||
return [courseName, department, number];
|
||||
}
|
||||
|
||||
@@ -109,16 +109,22 @@ function generateQuery(
|
||||
includeInstructor: boolean
|
||||
): [string, GradeDistributionParams] {
|
||||
const query = `
|
||||
select * from grade_distributions
|
||||
where Department_Code = :department_code
|
||||
and Course_Number = :course_number
|
||||
${includeInstructor ? `and Instructor_Last = :instructor_last collate nocase` : ''}
|
||||
${semester ? `and Semester = :semester` : ''}
|
||||
SELECT * FROM grade_distributions
|
||||
WHERE Department_Code = :department_code
|
||||
AND Course_Number COLLATE NOCASE IN (
|
||||
:course_number,
|
||||
concat('F', :course_number), -- Check summer courses with prefix, too
|
||||
concat('S', :course_number),
|
||||
concat('N', :course_number),
|
||||
concat('W', :course_number)
|
||||
)
|
||||
${includeInstructor ? `AND Instructor_Last = :instructor_last COLLATE NOCASE` : ''}
|
||||
${semester ? `AND Semester = :semester` : ''}
|
||||
`;
|
||||
|
||||
const params: GradeDistributionParams = {
|
||||
':department_code': course.department,
|
||||
':course_number': course.number,
|
||||
':course_number': course.getNumberWithoutTerm(),
|
||||
};
|
||||
|
||||
if (includeInstructor) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SiteSupport = {
|
||||
MY_UT: 'MY_UT',
|
||||
COURSE_CATALOG_SEARCH: 'COURSE_CATALOG_SEARCH',
|
||||
CLASSLIST: 'CLASSLIST',
|
||||
COURSE_CATALOG_KWS: 'COURSE_CATALOG_KWS',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,9 @@ export default function getSiteSupport(url: string): SiteSupportType | null {
|
||||
return SiteSupport.UT_PLANNER;
|
||||
}
|
||||
if (url.includes('utdirect.utexas.edu/apps/registrar/course_schedule')) {
|
||||
if (url.includes('kws_results')) {
|
||||
return SiteSupport.COURSE_CATALOG_KWS;
|
||||
}
|
||||
if (url.includes('results')) {
|
||||
return SiteSupport.COURSE_CATALOG_LIST;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,31 @@ const fixManifestOptionsPage = (): Plugin => ({
|
||||
},
|
||||
});
|
||||
|
||||
function getGitInfo() {
|
||||
// Try environment variables first (for Nix builds)
|
||||
if (process.env.VITE_GIT_BRANCH && process.env.VITE_GIT_COMMIT) {
|
||||
return {
|
||||
gitBranch: process.env.VITE_GIT_BRANCH,
|
||||
gitCommit: process.env.VITE_GIT_COMMIT,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to git commands (for local development)
|
||||
try {
|
||||
return {
|
||||
gitBranch: execSync('git rev-parse --abbrev-ref HEAD').toString().trim(),
|
||||
gitCommit: execSync('git rev-parse --short HEAD').toString().trim(),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
gitBranch: 'unknown',
|
||||
gitCommit: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const gitInfo = getGitInfo();
|
||||
|
||||
let config: ResolvedConfig;
|
||||
let server: ViteDevServer;
|
||||
|
||||
@@ -180,12 +205,14 @@ export default defineConfig({
|
||||
'PROD',
|
||||
'VITE_SENTRY_ENVIRONMENT',
|
||||
'VITE_BETA_BUILD',
|
||||
'VITE_GIT_BRANCH',
|
||||
'VITE_GIT_COMMIT',
|
||||
],
|
||||
includeTimestamp: true,
|
||||
includeBuildTime: true,
|
||||
customMetadata: {
|
||||
gitBranch: () => execSync('git rev-parse --abbrev-ref HEAD').toString().trim(),
|
||||
gitCommit: () => execSync('git rev-parse --short HEAD').toString().trim(),
|
||||
gitBranch: () => gitInfo.gitBranch,
|
||||
gitCommit: () => gitInfo.gitCommit,
|
||||
nodeVersion: () => process.version,
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user