diff --git a/package.json b/package.json index 9d8f56fa..2f0750ea 100644 --- a/package.json +++ b/package.json @@ -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", @@ -159,7 +162,10 @@ }, "overrides": { "es-module-lexer": "^1.5.4" - } + }, + "onlyBuiltDependencies": [ + "@tsparticles/engine" + ] }, "volta": { "node": "20.19.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e794b529..ad5571cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,15 @@ importers: '@tanstack/react-query': specifier: ^5.69.0 version: 5.69.0(react@18.3.1) + '@tsparticles/engine': + specifier: ^3.9.1 + version: 3.9.1 + '@tsparticles/react': + specifier: ^3.0.0 + version: 3.0.0(@tsparticles/engine@3.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tsparticles/slim': + specifier: ^3.9.1 + version: 3.9.1 '@unocss/vite': specifier: ^0.63.6 version: 0.63.6(patch_hash=9e2d2732a6e057a2ca90fba199730f252d8b4db8631b2c6ee0854fce7771bc95)(rollup@4.52.4)(typescript@5.7.3)(vite@5.4.20(@types/node@22.13.5)(sass@1.85.1)(terser@5.44.0)) @@ -2075,6 +2084,121 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@tsparticles/basic@3.9.1': + resolution: {integrity: sha512-ijr2dHMx0IQHqhKW3qA8tfwrR2XYbbWYdaJMQuBo2CkwBVIhZ76U+H20Y492j/NXpd1FUnt2aC0l4CEVGVGdeQ==} + + '@tsparticles/engine@3.9.1': + resolution: {integrity: sha512-DpdgAhWMZ3Eh2gyxik8FXS6BKZ8vyea+Eu5BC4epsahqTGY9V3JGGJcXC6lRJx6cPMAx1A0FaQAojPF3v6rkmQ==} + + '@tsparticles/interaction-external-attract@3.9.1': + resolution: {integrity: sha512-5AJGmhzM9o4AVFV24WH5vSqMBzOXEOzIdGLIr+QJf4fRh9ZK62snsusv/ozKgs2KteRYQx+L7c5V3TqcDy2upg==} + + '@tsparticles/interaction-external-bounce@3.9.1': + resolution: {integrity: sha512-bv05+h70UIHOTWeTsTI1AeAmX6R3s8nnY74Ea6p6AbQjERzPYIa0XY19nq/hA7+Nrg+EissP5zgoYYeSphr85A==} + + '@tsparticles/interaction-external-bubble@3.9.1': + resolution: {integrity: sha512-tbd8ox/1GPl+zr+KyHQVV1bW88GE7OM6i4zql801YIlCDrl9wgTDdDFGIy9X7/cwTvTrCePhrfvdkUamXIribQ==} + + '@tsparticles/interaction-external-connect@3.9.1': + resolution: {integrity: sha512-sq8YfUNsIORjXHzzW7/AJQtfi/qDqLnYG2qOSE1WOsog39MD30RzmiOloejOkfNeUdcGUcfsDgpUuL3UhzFUOA==} + + '@tsparticles/interaction-external-grab@3.9.1': + resolution: {integrity: sha512-QwXza+sMMWDaMiFxd8y2tJwUK6c+nNw554+/9+tEZeTTk2fCbB0IJ7p/TH6ZGWDL0vo2muK54Njv2fEey191ow==} + + '@tsparticles/interaction-external-pause@3.9.1': + resolution: {integrity: sha512-Gzv4/FeNir0U/tVM9zQCqV1k+IAgaFjDU3T30M1AeAsNGh/rCITV2wnT7TOGFkbcla27m4Yxa+Fuab8+8pzm+g==} + + '@tsparticles/interaction-external-push@3.9.1': + resolution: {integrity: sha512-GvnWF9Qy4YkZdx+WJL2iy9IcgLvzOIu3K7aLYJFsQPaxT8d9TF8WlpoMlWKnJID6H5q4JqQuMRKRyWH8aAKyQw==} + + '@tsparticles/interaction-external-remove@3.9.1': + resolution: {integrity: sha512-yPThm4UDWejDOWW5Qc8KnnS2EfSo5VFcJUQDWc1+Wcj17xe7vdSoiwwOORM0PmNBzdDpSKQrte/gUnoqaUMwOA==} + + '@tsparticles/interaction-external-repulse@3.9.1': + resolution: {integrity: sha512-/LBppXkrMdvLHlEKWC7IykFhzrz+9nebT2fwSSFXK4plEBxDlIwnkDxd3FbVOAbnBvx4+L8+fbrEx+RvC8diAw==} + + '@tsparticles/interaction-external-slow@3.9.1': + resolution: {integrity: sha512-1ZYIR/udBwA9MdSCfgADsbDXKSFS0FMWuPWz7bm79g3sUxcYkihn+/hDhc6GXvNNR46V1ocJjrj0u6pAynS1KQ==} + + '@tsparticles/interaction-particles-attract@3.9.1': + resolution: {integrity: sha512-CYYYowJuGwRLUixQcSU/48PTKM8fCUYThe0hXwQ+yRMLAn053VHzL7NNZzKqEIeEyt5oJoy9KcvubjKWbzMBLQ==} + + '@tsparticles/interaction-particles-collisions@3.9.1': + resolution: {integrity: sha512-ggGyjW/3v1yxvYW1IF1EMT15M6w31y5zfNNUPkqd/IXRNPYvm0Z0ayhp+FKmz70M5p0UxxPIQHTvAv9Jqnuj8w==} + + '@tsparticles/interaction-particles-links@3.9.1': + resolution: {integrity: sha512-MsLbMjy1vY5M5/hu/oa5OSRZAUz49H3+9EBMTIOThiX+a+vpl3sxc9AqNd9gMsPbM4WJlub8T6VBZdyvzez1Vg==} + + '@tsparticles/move-base@3.9.1': + resolution: {integrity: sha512-X4huBS27d8srpxwOxliWPUt+NtCwY+8q/cx1DvQxyqmTA8VFCGpcHNwtqiN+9JicgzOvSuaORVqUgwlsc7h4pQ==} + + '@tsparticles/move-parallax@3.9.1': + resolution: {integrity: sha512-whlOR0bVeyh6J/hvxf/QM3DqvNnITMiAQ0kro6saqSDItAVqg4pYxBfEsSOKq7EhjxNvfhhqR+pFMhp06zoCVA==} + + '@tsparticles/plugin-easing-quad@3.9.1': + resolution: {integrity: sha512-C2UJOca5MTDXKUTBXj30Kiqr5UyID+xrY/LxicVWWZPczQW2bBxbIbfq9ULvzGDwBTxE2rdvIB8YFKmDYO45qw==} + + '@tsparticles/plugin-hex-color@3.9.1': + resolution: {integrity: sha512-vZgZ12AjUicJvk7AX4K2eAmKEQX/D1VEjEPFhyjbgI7A65eX72M465vVKIgNA6QArLZ1DLs7Z787LOE6GOBWsg==} + + '@tsparticles/plugin-hsl-color@3.9.1': + resolution: {integrity: sha512-jJd1iGgRwX6eeNjc1zUXiJivaqC5UE+SC2A3/NtHwwoQrkfxGWmRHOsVyLnOBRcCPgBp/FpdDe6DIDjCMO715w==} + + '@tsparticles/plugin-rgb-color@3.9.1': + resolution: {integrity: sha512-SBxk7f1KBfXeTnnklbE2Hx4jBgh6I6HOtxb+Os1gTp0oaghZOkWcCD2dP4QbUu7fVNCMOcApPoMNC8RTFcy9wQ==} + + '@tsparticles/react@3.0.0': + resolution: {integrity: sha512-hjGEtTT1cwv6BcjL+GcVgH++KYs52bIuQGW3PWv7z3tMa8g0bd6RI/vWSLj7p//NZ3uTjEIeilYIUPBh7Jfq/Q==} + peerDependencies: + '@tsparticles/engine': ^3.0.2 + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@tsparticles/shape-circle@3.9.1': + resolution: {integrity: sha512-DqZFLjbuhVn99WJ+A9ajz9YON72RtCcvubzq6qfjFmtwAK7frvQeb6iDTp6Ze9FUipluxVZWVRG4vWTxi2B+/g==} + + '@tsparticles/shape-emoji@3.9.1': + resolution: {integrity: sha512-ifvY63usuT+hipgVHb8gelBHSeF6ryPnMxAAEC1RGHhhXfpSRWMtE6ybr+pSsYU52M3G9+TF84v91pSwNrb9ZQ==} + + '@tsparticles/shape-image@3.9.1': + resolution: {integrity: sha512-fCA5eme8VF3oX8yNVUA0l2SLDKuiZObkijb0z3Ky0qj1HUEVlAuEMhhNDNB9E2iELTrWEix9z7BFMePp2CC7AA==} + + '@tsparticles/shape-line@3.9.1': + resolution: {integrity: sha512-wT8NSp0N9HURyV05f371cHKcNTNqr0/cwUu6WhBzbshkYGy1KZUP9CpRIh5FCrBpTev34mEQfOXDycgfG0KiLQ==} + + '@tsparticles/shape-polygon@3.9.1': + resolution: {integrity: sha512-dA77PgZdoLwxnliH6XQM/zF0r4jhT01pw5y7XTeTqws++hg4rTLV9255k6R6eUqKq0FPSW1/WBsBIl7q/MmrqQ==} + + '@tsparticles/shape-square@3.9.1': + resolution: {integrity: sha512-DKGkDnRyZrAm7T2ipqNezJahSWs6xd9O5LQLe5vjrYm1qGwrFxJiQaAdlb00UNrexz1/SA7bEoIg4XKaFa7qhQ==} + + '@tsparticles/shape-star@3.9.1': + resolution: {integrity: sha512-kdMJpi8cdeb6vGrZVSxTG0JIjCwIenggqk0EYeKAwtOGZFBgL7eHhF2F6uu1oq8cJAbXPujEoabnLsz6mW8XaA==} + + '@tsparticles/slim@3.9.1': + resolution: {integrity: sha512-CL5cDmADU7sDjRli0So+hY61VMbdroqbArmR9Av+c1Fisa5ytr6QD7Jv62iwU2S6rvgicEe9OyRmSy5GIefwZw==} + + '@tsparticles/updater-color@3.9.1': + resolution: {integrity: sha512-XGWdscrgEMA8L5E7exsE0f8/2zHKIqnTrZymcyuFBw2DCB6BIV+5z6qaNStpxrhq3DbIxxhqqcybqeOo7+Alpg==} + + '@tsparticles/updater-life@3.9.1': + resolution: {integrity: sha512-Oi8aF2RIwMMsjssUkCB6t3PRpENHjdZf6cX92WNfAuqXtQphr3OMAkYFJFWkvyPFK22AVy3p/cFt6KE5zXxwAA==} + + '@tsparticles/updater-opacity@3.9.1': + resolution: {integrity: sha512-w778LQuRZJ+IoWzeRdrGykPYSSaTeWfBvLZ2XwYEkh/Ss961InOxZKIpcS6i5Kp/Zfw0fS1ZAuqeHwuj///Osw==} + + '@tsparticles/updater-out-modes@3.9.1': + resolution: {integrity: sha512-cKQEkAwbru+hhKF+GTsfbOvuBbx2DSB25CxOdhtW2wRvDBoCnngNdLw91rs+0Cex4tgEeibkebrIKFDDE6kELg==} + + '@tsparticles/updater-rotate@3.9.1': + resolution: {integrity: sha512-9BfKaGfp28JN82MF2qs6Ae/lJr9EColMfMTHqSKljblwbpVDHte4umuwKl3VjbRt87WD9MGtla66NTUYl+WxuQ==} + + '@tsparticles/updater-size@3.9.1': + resolution: {integrity: sha512-3NSVs0O2ApNKZXfd+y/zNhTXSFeG1Pw4peI8e6z/q5+XLbmue9oiEwoPy/tQLaark3oNj3JU7Q903ZijPyXSzw==} + + '@tsparticles/updater-stroke-color@3.9.1': + resolution: {integrity: sha512-3x14+C2is9pZYTg9T2TiA/aM1YMq4wLdYaZDcHm3qO30DZu5oeQq0rm/6w+QOGKYY1Z3Htg9rlSUZkhTHn7eDA==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -8820,6 +8944,188 @@ snapshots: '@trysound/sax@0.2.0': {} + '@tsparticles/basic@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + '@tsparticles/move-base': 3.9.1 + '@tsparticles/plugin-hex-color': 3.9.1 + '@tsparticles/plugin-hsl-color': 3.9.1 + '@tsparticles/plugin-rgb-color': 3.9.1 + '@tsparticles/shape-circle': 3.9.1 + '@tsparticles/updater-color': 3.9.1 + '@tsparticles/updater-opacity': 3.9.1 + '@tsparticles/updater-out-modes': 3.9.1 + '@tsparticles/updater-size': 3.9.1 + + '@tsparticles/engine@3.9.1': {} + + '@tsparticles/interaction-external-attract@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-bounce@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-bubble@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-connect@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-grab@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-pause@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-push@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-remove@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-repulse@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-external-slow@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-particles-attract@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-particles-collisions@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/interaction-particles-links@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/move-base@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/move-parallax@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/plugin-easing-quad@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/plugin-hex-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/plugin-hsl-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/plugin-rgb-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/react@3.0.0(@tsparticles/engine@3.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tsparticles/engine': 3.9.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tsparticles/shape-circle@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-emoji@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-image@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-line@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-polygon@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-square@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/shape-star@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/slim@3.9.1': + dependencies: + '@tsparticles/basic': 3.9.1 + '@tsparticles/engine': 3.9.1 + '@tsparticles/interaction-external-attract': 3.9.1 + '@tsparticles/interaction-external-bounce': 3.9.1 + '@tsparticles/interaction-external-bubble': 3.9.1 + '@tsparticles/interaction-external-connect': 3.9.1 + '@tsparticles/interaction-external-grab': 3.9.1 + '@tsparticles/interaction-external-pause': 3.9.1 + '@tsparticles/interaction-external-push': 3.9.1 + '@tsparticles/interaction-external-remove': 3.9.1 + '@tsparticles/interaction-external-repulse': 3.9.1 + '@tsparticles/interaction-external-slow': 3.9.1 + '@tsparticles/interaction-particles-attract': 3.9.1 + '@tsparticles/interaction-particles-collisions': 3.9.1 + '@tsparticles/interaction-particles-links': 3.9.1 + '@tsparticles/move-parallax': 3.9.1 + '@tsparticles/plugin-easing-quad': 3.9.1 + '@tsparticles/shape-emoji': 3.9.1 + '@tsparticles/shape-image': 3.9.1 + '@tsparticles/shape-line': 3.9.1 + '@tsparticles/shape-polygon': 3.9.1 + '@tsparticles/shape-square': 3.9.1 + '@tsparticles/shape-star': 3.9.1 + '@tsparticles/updater-life': 3.9.1 + '@tsparticles/updater-rotate': 3.9.1 + '@tsparticles/updater-stroke-color': 3.9.1 + + '@tsparticles/updater-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-life@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-opacity@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-out-modes@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-rotate@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-size@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + + '@tsparticles/updater-stroke-color@3.9.1': + dependencies: + '@tsparticles/engine': 3.9.1 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': diff --git a/src/views/components/calendar/CalendarSchedules.tsx b/src/views/components/calendar/CalendarSchedules.tsx index d7e10ab5..d2cb1555 100644 --- a/src/views/components/calendar/CalendarSchedules.tsx +++ b/src/views/components/calendar/CalendarSchedules.tsx @@ -27,7 +27,7 @@ export function CalendarSchedules() { return (
-
+
MY SCHEDULES diff --git a/src/views/components/settings/AdvancedSettings.tsx b/src/views/components/settings/AdvancedSettings.tsx new file mode 100644 index 00000000..4b2707fe --- /dev/null +++ b/src/views/components/settings/AdvancedSettings.tsx @@ -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) => Promise; +} + +/** + * Settings section component for advanced settings + */ +export const AdvancedSettings: React.FC = ({ + highlightConflicts, + setHighlightConflicts, + loadAllCourses, + setLoadAllCourses, + increaseScheduleLimit, + setIncreaseScheduleLimit, + calendarNewTab, + setCalendarNewTab, + activeSchedule, + handleEraseAll, + handleImportClick, +}) => ( +
+

ADVANCED SETTINGS

+
+
+
+
+ + Export Current Schedule + +

Backup your active schedule to a portable file

+
+ +
+ + + +
+
+ + Import Schedule + +

Import from a schedule file

+
+ + Import Schedule + +
+ + + +
+
+ + Course Conflict Highlight + +

+ Adds a red strikethrough to courses that have conflicting times. +

+
+ { + setHighlightConflicts(!highlightConflicts); + OptionsStore.set('enableHighlightConflicts', !highlightConflicts); + }} + /> +
+ + + +
+
+ + Load All Courses in Course Schedule + +

+ Loads all courses in the Course Schedule site by scrolling, instead of using next/prev page + buttons. +

+
+ { + setLoadAllCourses(!loadAllCourses); + OptionsStore.set('enableScrollToLoad', !loadAllCourses); + }} + /> +
+ + + +
+
+ + Allow more than 10 schedules + +

+ Allow bypassing the 10-schedule limit. Intended for advisors or staff who need to create + many schedules on behalf of students. +

+
+ { + setIncreaseScheduleLimit(!increaseScheduleLimit); + OptionsStore.set('allowMoreSchedules', !increaseScheduleLimit); + }} + /> +
+ + + +
+
+ + Always Open Calendar in New Tab + +

+ Always opens the calendar view in a new tab when navigating to the calendar page. May + prevent issues where the calendar refuses to open. +

+
+ { + setCalendarNewTab(!calendarNewTab); + OptionsStore.set('alwaysOpenCalendarInNewTab', !calendarNewTab); + }} + /> +
+ + + +
+
+ + Reset All Data + +

Erases all schedules and courses you have.

+
+ +
+
+ {DISPLAY_PREVIEWS && ( + + + 01234 MWF 10:00 AM - 11:00 AM UTC 1.234 + + + )} +
+
+); diff --git a/src/views/components/settings/ContributorCard.tsx b/src/views/components/settings/ContributorCard.tsx new file mode 100644 index 00000000..af7efb61 --- /dev/null +++ b/src/views/components/settings/ContributorCard.tsx @@ -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 = ({ + name, + githubUsername, + roles, + stats, + showStats, + includeMergedPRs, +}) => ( +
+ window.open(`https://github.com/${githubUsername}`, '_blank')} + > + {name} + + {roles.map(role => ( +

+ {role} +

+ ))} + {showStats && stats && ( +
+

GitHub Stats (UTRP repo):

+ {includeMergedPRs && stats.mergedPRs !== undefined && ( +

Merged PRs: {stats.mergedPRs}

+ )} +

Commits: {stats.commits}

+

{stats.linesAdded}++

+

{stats.linesDeleted}--

+
+ )} +
+); diff --git a/src/views/components/settings/Settings.tsx b/src/views/components/settings/Settings.tsx index f6218d3b..daa62765 100644 --- a/src/views/components/settings/Settings.tsx +++ b/src/views/components/settings/Settings.tsx @@ -1,115 +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 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 { CRX_PAGES } from '@shared/types/CRXPages'; -import MIMEType from '@shared/types/MIMEType'; -// 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 { handleExportJson } from '../calendar/utils'; -// 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(false); - // const [_showTimeLocation, setShowTimeLocation] = useState(false); - const [highlightConflicts, setHighlightConflicts] = useState(false); - const [loadAllCourses, setLoadAllCourses] = useState(false); - const [_enableDataRefreshing, setEnableDataRefreshing] = useState(false); - const [calendarNewTab, setCalendarNewTab] = useState(false); - const [increaseScheduleLimit, setIncreaseScheduleLimit] = useState(false); + const gitHubStatsService = useMemo(() => new GitHubStatsService(), []); - const showMigrationDialog = useMigrationDialog(); - - // Toggle GitHub stats when the user presses the 'S' key - const [showGitHubStats, setShowGitHubStats] = useState(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 > | null>(null); + const [isDeveloper, setIsDeveloper] = useState(false); const [activeSchedule] = useSchedules(); - // const [isRefreshing, setIsRefreshing] = useState(false); - - const [isDeveloper, setIsDeveloper] = useState(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 { @@ -121,19 +74,10 @@ export default function Settings(): JSX.Element { }; const initAndSetSettings = async () => { - const { - enableCourseStatusChips, - enableHighlightConflicts, - enableScrollToLoad, - enableDataRefreshing, - alwaysOpenCalendarInNewTab, - allowMoreSchedules, - } = await initSettings(); - setEnableCourseStatusChips(enableCourseStatusChips); - // setShowTimeLocation(enableTimeAndLocationInPopup); + const { enableHighlightConflicts, enableScrollToLoad, alwaysOpenCalendarInNewTab, allowMoreSchedules } = + await initSettings(); setHighlightConflicts(enableHighlightConflicts); setLoadAllCourses(enableScrollToLoad); - setEnableDataRefreshing(enableDataRefreshing); setCalendarNewTab(alwaysOpenCalendarInNewTab); setIncreaseScheduleLimit(allowMoreSchedules); }; @@ -143,79 +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); - }); - - const l6 = OptionsStore.listen('alwaysOpenCalendarInNewTab', async ({ newValue }) => { - setCalendarNewTab(newValue); - // console.log('alwaysOpenCalendarInNewTab', newValue); - }); - - const l7 = OptionsStore.listen('allowMoreSchedules', async ({ newValue }) => { - setIncreaseScheduleLimit(newValue); - }); - - // Remove listeners when the component is unmounted return () => { OptionsStore.removeListener(l1); OptionsStore.removeListener(l2); OptionsStore.removeListener(l3); OptionsStore.removeListener(l4); - OptionsStore.removeListener(l5); - OptionsStore.removeListener(l6); - OptionsStore.removeListener(l7); - DevStore.removeListener(ds_l1); - window.removeEventListener('keydown', handleKeyPress); }; - }, []); + }, [gitHubStatsService]); - const handleEraseAll = () => { + const handleEraseAll = useCallback(() => { showDialog({ title: 'Erase All Course/Schedule Data', description: ( @@ -242,9 +157,9 @@ export default function Settings(): JSX.Element { ), }); - }; + }, [showDialog]); - const handleImportClick = async (event: React.ChangeEvent) => { + const handleImportClick = useCallback(async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; @@ -257,16 +172,30 @@ export default function Settings(): JSX.Element { console.error('Error importing schedule:', error); alert('Failed to import schedule. Make sure the file is a valid .json format.'); } - }; - // const handleAddCourseByLink = async () => { - // // todo: Use a proper modal instead of a prompt - // const link: string | null = prompt('Enter course link'); - // // Exit if the user cancels the prompt - // if (link === null) return; - // await addCourseByUrl(link, activeSchedule); - // }; + }, []); - const [devMode, toggleDevMode] = useDevMode(10); + const sortedContributors = useMemo(() => { + if (!githubStats) return LONGHORN_DEVELOPERS_SWE; + return [...LONGHORN_DEVELOPERS_SWE].sort( + (a, b) => + (githubStats.userGitHubStats[b.githubUsername]?.commits ?? 0) - + (githubStats.userGitHubStats[a.githubUsername]?.commits ?? 0) + ); + }, [githubStats]); + + const additionalContributors = useMemo(() => { + if (!githubStats) return []; + return Object.keys(githubStats.userGitHubStats) + .filter( + username => + !LONGHORN_DEVELOPERS_ADMINS.some(admin => admin.githubUsername === username) && + !LONGHORN_DEVELOPERS_SWE.some(swe => swe.githubUsername === username) + ) + .sort( + (a, b) => + (githubStats.userGitHubStats[b]?.commits ?? 0) - (githubStats.userGitHubStats[a]?.commits ?? 0) + ); + }, [githubStats]); if (devMode) { DevStore.set('isDeveloper', true); @@ -274,13 +203,32 @@ export default function Settings(): JSX.Element { } return ( -
+
+ {particlesInit && showParticles && ( + + )} +
- - Settings and Credits - +
+ + Settings and Credits + + {isBirthday && ( + + 🎉 Happy Birthday LHD! 🎉 + + )} +
-
- - */} - -
-
- - Export Current Schedule - -

- Backup your active schedule to a portable file -

-
- -
- - - -
-
- - Import Schedule - -

Import from a schedule file

-
- - Import Schedule - -
- - - -
-
- - Course Conflict Highlight - -

- Adds a red strikethrough to courses that have conflicting times. -

-
- { - setHighlightConflicts(!highlightConflicts); - OptionsStore.set('enableHighlightConflicts', !highlightConflicts); - }} - /> -
- - - -
-
- - Load All Courses in Course Schedule - -

- Loads all courses in the Course Schedule site by scrolling, instead of using - next/prev page buttons. -

-
- { - setLoadAllCourses(!loadAllCourses); - OptionsStore.set('enableScrollToLoad', !loadAllCourses); - }} - /> -
- -
-
- - Allow more than 10 schedules - -

- Allow bypassing the 10-schedule limit. Intended for advisors or staff who - need to create many schedules on behalf of students. -

-
- { - setIncreaseScheduleLimit(!increaseScheduleLimit); - OptionsStore.set('allowMoreSchedules', !increaseScheduleLimit); - }} - /> -
- - - -
-
- - Always Open Calendar in New Tab - -

- Always opens the calendar view in a new tab when navigating to the calendar - page. May prevent issues where the calendar refuses to open. -

-
- { - setCalendarNewTab(!calendarNewTab); - OptionsStore.set('alwaysOpenCalendarInNewTab', !calendarNewTab); - }} - /> -
- - - -
-
- - Reset All Data - -

- Erases all schedules and courses you have. -

-
- -
-
- {DISPLAY_PREVIEWS && ( - - - 01234 MWF 10:00 AM - 11:00 AM UTC 1.234 - - - )} -
- + @@ -593,17 +322,21 @@ export default function Settings(): JSX.Element { Open Debug Page
+ + + + + )} - - - - -
@@ -616,144 +349,44 @@ export default function Settings(): JSX.Element {
{LONGHORN_DEVELOPERS_ADMINS.map(admin => ( -
- - window.open(`https://github.com/${admin.githubUsername}`, '_blank') - } - > - {admin.name} - - {admin.role.map(role => ( -

- {role} -

- ))} - {showGitHubStats && githubStats && ( -
-

GitHub Stats (UTRP repo):

- {includeMergedPRs && ( -

- Merged PRS:{' '} - {githubStats.adminGitHubStats[admin.githubUsername]?.mergedPRs} -

- )} -

- Commits: {githubStats.adminGitHubStats[admin.githubUsername]?.commits} -

-

- {githubStats.adminGitHubStats[admin.githubUsername]?.linesAdded} ++ -

-

- {githubStats.adminGitHubStats[admin.githubUsername]?.linesDeleted} -- -

-
- )} -
+ name={admin.name} + githubUsername={admin.githubUsername} + roles={admin.role} + stats={githubStats?.adminGitHubStats[admin.githubUsername]} + showStats={showGitHubStats} + includeMergedPRs={INCLUDE_MERGED_PRS} + /> ))}
+

UTRP CONTRIBUTORS

- {LONGHORN_DEVELOPERS_SWE.sort( - (a, b) => - (githubStats?.userGitHubStats[b.githubUsername]?.commits ?? 0) - - (githubStats?.userGitHubStats[a.githubUsername]?.commits ?? 0) - ).map(swe => ( -
( + - - window.open(`https://github.com/${swe.githubUsername}`, '_blank') - } - > - {swe.name} - - {swe.role.map(role => ( -

- {role} -

- ))} - {showGitHubStats && githubStats && ( -
-

GitHub Stats (UTRP repo):

- {includeMergedPRs && ( -

- Merged PRS:{' '} - {githubStats.userGitHubStats[swe.githubUsername]?.mergedPRs} -

- )} -

- Commits: {githubStats.userGitHubStats[swe.githubUsername]?.commits} -

-

- {githubStats.userGitHubStats[swe.githubUsername]?.linesAdded} ++ -

-

- {githubStats.userGitHubStats[swe.githubUsername]?.linesDeleted} -- -

-
- )} -
+ name={swe.name} + githubUsername={swe.githubUsername} + roles={swe.role} + stats={githubStats?.userGitHubStats[swe.githubUsername]} + showStats={showGitHubStats} + includeMergedPRs={INCLUDE_MERGED_PRS} + /> + ))} + {additionalContributors.map(username => ( + ))} - {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 => ( -
- window.open(`https://github.com/${username}`, '_blank')} - > - {githubStats.names[username]} - -

Contributor

- {showGitHubStats && ( -
-

GitHub Stats (UTRP repo):

- {includeMergedPRs && ( -

- Merged PRs:{' '} - {githubStats.userGitHubStats[username]?.mergedPRs} -

- )} -

- Commits: {githubStats.userGitHubStats[username]?.commits} -

-

- {githubStats.userGitHubStats[username]?.linesAdded} ++ -

-

- {githubStats.userGitHubStats[username]?.linesDeleted} -- -

-
- )} -
- ))}
diff --git a/src/views/components/settings/constants.ts b/src/views/components/settings/constants.ts new file mode 100644 index 00000000..d7c5768d --- /dev/null +++ b/src/views/components/settings/constants.ts @@ -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; diff --git a/src/views/components/settings/useBirthdayCelebration.ts b/src/views/components/settings/useBirthdayCelebration.ts new file mode 100644 index 00000000..01cbf7f5 --- /dev/null +++ b/src/views/components/settings/useBirthdayCelebration.ts @@ -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 }; +}; diff --git a/src/views/components/settings/useDevMode.ts b/src/views/components/settings/useDevMode.ts new file mode 100644 index 00000000..a4a0873b --- /dev/null +++ b/src/views/components/settings/useDevMode.ts @@ -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]; +};