├── .env.local.sample ├── .eslintrc.json ├── .github └── workflows │ ├── main.yml │ └── style.yml ├── .gitignore ├── .nvmrc ├── .stylelintrc.json ├── .tool-versions ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── HELPING_GUIDE.md ├── README.md ├── assets ├── calendarium-banner-dark.png └── calendarium-banner-light.png ├── bun.lockb ├── components ├── CalendarExportModal │ ├── CalendarExportModal.tsx │ └── index.ts ├── ClearSelectionButton │ ├── ClearSelectionButton.tsx │ └── index.ts ├── ConfirmPopUpButton │ ├── ConfirmPopUpButton.tsx │ └── index.ts ├── CustomToolbar │ ├── CustomToolbar.tsx │ └── index.ts ├── DarkModeToggler │ ├── DarkModeToggler.tsx │ └── index.ts ├── EventFilters │ ├── EventFilters.tsx │ └── index.ts ├── EventModal │ ├── EventModal.tsx │ └── index.ts ├── ExportButton │ ├── ExportButton.tsx │ └── index.ts ├── FilterBlock │ ├── FilterBlock.tsx │ └── index.ts ├── Install │ ├── Install.tsx │ └── index.ts ├── Layout │ ├── Layout.tsx │ ├── index.ts │ └── layout.module.scss ├── MoreButton │ ├── MoreButton.tsx │ └── index.ts ├── NavigationPane │ ├── NavigationPane.tsx │ └── index.ts ├── Notifications │ ├── Notifications.tsx │ └── index.tsx ├── ScheduleFilters │ ├── ScheduleFilters.tsx │ └── index.ts ├── Settings │ ├── Settings.tsx │ └── index.ts ├── ShareButton │ ├── ShareButton.tsx │ └── index.ts ├── ShareModal │ ├── ShareModal.tsx │ └── index.ts ├── ShiftModal │ ├── ShiftModal.tsx │ └── index.ts ├── Sidebar │ ├── Sidebar.tsx │ └── index.ts └── Themes │ ├── Themes.tsx │ └── index.tsx ├── contexts └── AppInfoProvider.tsx ├── data ├── filters.json ├── mei_perfis.ts └── shifts.json ├── dtos └── index.ts ├── hooks ├── useColorTheme.ts └── useWindowSize.ts ├── netlify.toml ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── api │ ├── export │ │ ├── events.tsx │ │ └── schedule.tsx │ └── transfer │ │ ├── events.tsx │ │ └── notifications.tsx ├── index.tsx └── schedule.tsx ├── postcss.config.js ├── public ├── calendarium-dark.svg ├── calendarium-light.svg ├── cesium-DARK.svg ├── cesium-LIGHT.svg ├── cesium.ico ├── favicon-calendarium.ico ├── manifest.webmanifest └── pwa │ ├── android │ ├── android-launchericon-144-144.png │ ├── android-launchericon-192-192.png │ ├── android-launchericon-48-48.png │ ├── android-launchericon-512-512.png │ ├── android-launchericon-72-72.png │ └── android-launchericon-96-96.png │ ├── ios │ ├── 100.png │ ├── 1024.png │ ├── 114.png │ ├── 120.png │ ├── 128.png │ ├── 144.png │ ├── 152.png │ ├── 16.png │ ├── 167.png │ ├── 180.png │ ├── 192.png │ ├── 20.png │ ├── 256.png │ ├── 29.png │ ├── 32.png │ ├── 40.png │ ├── 50.png │ ├── 512.png │ ├── 57.png │ ├── 58.png │ ├── 60.png │ ├── 64.png │ ├── 72.png │ ├── 76.png │ ├── 80.png │ └── 87.png │ ├── screenshots │ ├── 1.jpg │ ├── 2.jpg │ ├── 3.jpg │ ├── 4.jpg │ ├── 5.jpg │ └── 6.jpg │ ├── splashscreens │ ├── ipad_splash.png │ ├── ipadpro1_splash.png │ ├── ipadpro2_splash.png │ ├── ipadpro3_splash.png │ ├── iphone5_splash.png │ ├── iphone6_splash.png │ ├── iphoneplus_splash.png │ ├── iphonex_splash.png │ ├── iphonexr_splash.png │ └── iphonexsmax_splash.png │ └── windows11 │ ├── LargeTile.scale-100.png │ ├── LargeTile.scale-125.png │ ├── LargeTile.scale-150.png │ ├── LargeTile.scale-200.png │ ├── LargeTile.scale-400.png │ ├── SmallTile.scale-100.png │ ├── SmallTile.scale-125.png │ ├── SmallTile.scale-150.png │ ├── SmallTile.scale-200.png │ ├── SmallTile.scale-400.png │ ├── SplashScreen.scale-100.png │ ├── SplashScreen.scale-125.png │ ├── SplashScreen.scale-150.png │ ├── SplashScreen.scale-200.png │ ├── SplashScreen.scale-400.png │ ├── Square150x150Logo.scale-100.png │ ├── Square150x150Logo.scale-125.png │ ├── Square150x150Logo.scale-150.png │ ├── Square150x150Logo.scale-200.png │ ├── Square150x150Logo.scale-400.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-16.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-20.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-24.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-256.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-30.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-32.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-36.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-40.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-44.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-48.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-60.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-64.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-72.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-80.png │ ├── Square44x44Logo.altform-lightunplated_targetsize-96.png │ ├── Square44x44Logo.altform-unplated_targetsize-16.png │ ├── Square44x44Logo.altform-unplated_targetsize-20.png │ ├── Square44x44Logo.altform-unplated_targetsize-24.png │ ├── Square44x44Logo.altform-unplated_targetsize-256.png │ ├── Square44x44Logo.altform-unplated_targetsize-30.png │ ├── Square44x44Logo.altform-unplated_targetsize-32.png │ ├── Square44x44Logo.altform-unplated_targetsize-36.png │ ├── Square44x44Logo.altform-unplated_targetsize-40.png │ ├── Square44x44Logo.altform-unplated_targetsize-44.png │ ├── Square44x44Logo.altform-unplated_targetsize-48.png │ ├── Square44x44Logo.altform-unplated_targetsize-60.png │ ├── Square44x44Logo.altform-unplated_targetsize-64.png │ ├── Square44x44Logo.altform-unplated_targetsize-72.png │ ├── Square44x44Logo.altform-unplated_targetsize-80.png │ ├── Square44x44Logo.altform-unplated_targetsize-96.png │ ├── Square44x44Logo.scale-100.png │ ├── Square44x44Logo.scale-125.png │ ├── Square44x44Logo.scale-150.png │ ├── Square44x44Logo.scale-200.png │ ├── Square44x44Logo.scale-400.png │ ├── Square44x44Logo.targetsize-16.png │ ├── Square44x44Logo.targetsize-20.png │ ├── Square44x44Logo.targetsize-24.png │ ├── Square44x44Logo.targetsize-256.png │ ├── Square44x44Logo.targetsize-30.png │ ├── Square44x44Logo.targetsize-32.png │ ├── Square44x44Logo.targetsize-36.png │ ├── Square44x44Logo.targetsize-40.png │ ├── Square44x44Logo.targetsize-44.png │ ├── Square44x44Logo.targetsize-48.png │ ├── Square44x44Logo.targetsize-60.png │ ├── Square44x44Logo.targetsize-64.png │ ├── Square44x44Logo.targetsize-72.png │ ├── Square44x44Logo.targetsize-80.png │ ├── Square44x44Logo.targetsize-96.png │ ├── StoreLogo.scale-100.png │ ├── StoreLogo.scale-125.png │ ├── StoreLogo.scale-150.png │ ├── StoreLogo.scale-200.png │ ├── StoreLogo.scale-400.png │ ├── Wide310x150Logo.scale-100.png │ ├── Wide310x150Logo.scale-125.png │ ├── Wide310x150Logo.scale-150.png │ ├── Wide310x150Logo.scale-200.png │ └── Wide310x150Logo.scale-400.png ├── scraper ├── .gitignore ├── README.md ├── main.py ├── modules │ ├── README.md │ ├── course_scraper.py │ ├── create_filters.py │ ├── increment_time.py │ ├── schedule_scraper.py │ ├── subjects_scraper.py │ └── subjects_short_names_scraper.py ├── requirements.txt ├── subjects.json └── subjects_short_names.json ├── styles ├── events.module.css ├── globals.css └── schedule.module.css ├── tailwind.config.js ├── tsconfig.json ├── types └── index.ts └── utils └── index.ts /.env.local.sample: -------------------------------------------------------------------------------- 1 | GS_PRIVATE_KEY= 2 | GS_CLIENT_EMAIL= 3 | SHEET_ID= 4 | NEXT_PUBLIC_DOMAIN=http://localhost:3000 5 | UMAMI_URL= 6 | UMAMI_WEBSITE_ID= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Calendarium Scraper 2 | 3 | on: 4 | schedule: 5 | # run every 7 days 6 | - cron: "0 0 * * 0" 7 | workflow_dispatch: # enables manual triggering 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Code 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Firefox 18 | uses: browser-actions/setup-firefox@v1 19 | 20 | - name: Firefox Version 21 | run: firefox --version 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: 3.11.5 27 | 28 | - name: Install Dependencies 29 | run: pip install -r scraper/requirements.txt 30 | 31 | - name: Run the Scraper 32 | run: python scraper/main.py 33 | 34 | - name: Set up Node 35 | uses: actions/setup-node@v3 36 | with: 37 | node-version: 22.11.0 38 | 39 | - name: Setup up Bun 40 | uses: oven-sh/setup-bun@v2 41 | with: 42 | bun-version: 1.1.32 43 | 44 | - name: Format Code with Prettier 45 | run: | 46 | bun i 47 | bun format 48 | 49 | - name: Get Most Active Contributors 50 | id: get_contributors 51 | run: | 52 | # Fetch contributors using GitHub API 53 | contributors=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 54 | "https://api.github.com/repos/${{ github.repository }}/contributors?per_page=1") 55 | 56 | # Extract the login names of the top contributors 57 | echo "::set-output name=reviewers::$(echo $contributors | jq -r '.[].login' | tr '\n' ',')" 58 | 59 | - name: Create Pull Request 60 | uses: peter-evans/create-pull-request@v5 61 | with: 62 | title: "chore: update shifts data" 63 | body: | 64 | *This is an automated PR* 65 | Run Calendarium Scraper in order to update shifts data. 66 | > [!IMPORTANT] 67 | > Before merging, please be aware of edge cases where manual intervention is needed. 68 | commit-message: "chore: update shifts data" 69 | branch: action/shifts 70 | base: master 71 | delete-branch: true 72 | labels: add activities, automated 73 | reviewers: ${{ steps.get_contributors.outputs.reviewers }} 74 | token: ${{ secrets.GITHUB_TOKEN }} 75 | author: "GitHub " 76 | committer: "GitHub " 77 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: CI Style 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | types: [opened, synchronize] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Setup up Node 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 22.11.0 18 | 19 | - name: Setup up Bun 20 | uses: oven-sh/setup-bun@v2 21 | with: 22 | bun-version: 1.1.32 23 | 24 | - name: Install dependencies 25 | run: bun i 26 | 27 | - name: Format the code 28 | run: bun format 29 | 30 | - name: Lint the code 31 | run: bun run test:lint 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | .next 39 | 40 | # Macos 41 | .DS_Store 42 | 43 | # local env files 44 | .env.* 45 | !.env.*.sample 46 | 47 | # venv 48 | *venv 49 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.11.0 2 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-prettier/recommended", "stylelint-config-recess-order"] 3 | } 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22.11.0 2 | bun 1.1.32 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [asdf-vm]: https://asdf-vm.com/ 2 | 3 | # 🚀 Getting Started 4 | 5 | These instructions will get you a copy of the project up and running on your 6 | local machine for development and testing purposes. 7 | 8 | ## 📥 Prerequisites 9 | 10 | The following software is required to be installed on your system: 11 | 12 | - [Node.js 16.15+](https://nodejs.org/en/download/) 13 | 14 | We recommend using [asdf version manager][asdf-vm] to install and manage all the programming languages' requirements. 15 | 16 | ## 🔧 Setup 17 | 18 | Install all dependencies. 19 | 20 | ``` 21 | npm install 22 | ``` 23 | 24 | ## 🔨 Development 25 | 26 | Starting the development server. 27 | 28 | ``` 29 | npm run dev 30 | ``` 31 | 32 | Test your code against common guidelines. 33 | 34 | ``` 35 | npm run test 36 | ``` 37 | 38 | Lint your code. 39 | 40 | ``` 41 | npm run lint 42 | ``` 43 | 44 | Format your code. 45 | 46 | ``` 47 | npm run format 48 | ``` 49 | 50 | ## 🔗 References 51 | 52 | You can use these resources to learn more about the technologies this project 53 | uses. 54 | 55 | - [Getting Started with React](https://reactjs.org/docs/getting-started.html) 56 | - [Learn Next.js](https://nextjs.org/learn) 57 | - [Getting Started with `react-big-calendar`](https://github.com/jquense/react-big-calendar) 58 | -------------------------------------------------------------------------------- /HELPING_GUIDE.md: -------------------------------------------------------------------------------- 1 | # 📬 Helping Guide 2 | 3 | > Anything wrong or missing? Learn how to help Calendarium! 4 | 5 | ## 🖇️ Forking the repository 6 | 7 | **1.** Go to [Calendarium](https://github.com/cesium/calendarium) 8 | 9 | **2.** In the top right corner, click on **Fork** 10 | 11 | ## 🔗 Cloning the forked repository to your machine 12 | 13 | **1.** Go to _your fork_ and click **Code** 14 | 15 | > **Note** 16 | > The link to your forked repository should be `https://github.com//calendarium` 17 | 18 | **2.** Copy the given HTTPS link 19 | 20 | **3.** On your terminal, run the following command: 21 | 22 | ``` 23 | git clone 24 | ``` 25 | 26 | > **Note** 27 | > If you'd rather clone with SSH, copy the SSH url and run `git clone ` 28 | 29 | **Now you have a copy of the repository that you can work with.** 30 | 31 | > **Note** 32 | > The following instructions are targeted for a Linux environment, you can however use the web-based editing capabilities of GitHub. 33 | 34 | ## ⛓️ Creating a branch 35 | 36 | **1.** Move to your cloned repository directory with `cd calendarium` 37 | 38 | **2.** Create a new branch with the following command: 39 | 40 | ``` 41 | git checkout -b 42 | ``` 43 | 44 | Where `` is the name of your branch. 45 | 46 | > **Note** 47 | > The name of your branch should follow the CeSIUM guidelines: ` + '/' + `. 48 | > For example: `dm/shifts`. 49 | 50 | ## 🗃️ Making your changes 51 | 52 | **1.** Move to the `data` directory 53 | 54 | **2.** Open the `events.json` or `shifts.json` to make changes to **Events** or **Shifts** 55 | 56 | **Understanding `events.json`** 57 | 58 | - `title` - event title 59 | - `place` - where the event takes place _(optional)_ 60 | - `link` - a relevant link _(optional)_ 61 | - `start` - **date and time** of when the event starts 62 | - `end` - **date and time** of when the event ends 63 | - `groupId` - an ID composed of the course year 64 | - `filterId`\* - an ID used for filtering 65 | 66 | > **Note** 67 | > The `filterId` is composed of: ``. 68 | > For example: `221` (Bases de Dados). 69 | > Check out the `filterId` of each class by searching its name on `shifts.json` or checking the `id` parameter in `filters.json`. 70 | 71 | > **Note** 72 | > For all-day events, `start` and `end` should be equal and composed of: ` 00:00`. And for multiple-days events: `start: 00:00` & `end: 23:59`. Check out the example below. 73 | 74 | Check out this example: 75 | 76 | ```json 77 | { 78 | "title": "[Lógica] Teste", 79 | "place": "CP2 - 0.20 + 1.01 + 1.05 + 1.07 + 2.01 + 2.02", 80 | "start": "2023-04-12 17:30", 81 | "end": "2023-04-12 20:30", 82 | "groupId": 1, 83 | "filterId": 124 84 | } 85 | ``` 86 | 87 | For All-Day events: 88 | 89 | ```json 90 | { 91 | "title": "[POO] Entrega TP", 92 | "start": "2023-05-14 00:00", 93 | "end": "2023-05-14 00:00", 94 | "groupId": 2, 95 | "filterId": 224 96 | } 97 | ``` 98 | 99 | For Multiple-Days events: 100 | 101 | ```json 102 | { 103 | "title": "[POO] Apresentação TP", 104 | "start": "2023-05-15 00:00", 105 | "end": "2023-05-19 23:59", 106 | "groupId": 2, 107 | "filterId": 224 108 | } 109 | ``` 110 | 111 | **Understanding `shifts.json`** 112 | 113 | - `id`\* - curricular unit id 114 | - `title` - title of the activity 115 | - `theoretical` - 'true' for T and 'false' for TP 116 | - `shift` - class shift 117 | - `building`\* - building where the class takes place 118 | - `room` - room where the class takes place 119 | - `day` - week day (1 - Monday ... 4 - Friday) 120 | - `start` - **time** of when the activity starts 121 | - `end` - **time** of when the activity ends 122 | - `filterId`\* - an ID used for filtering 123 | 124 | > **Note** 125 | > The curricular unit `id` is taken from the official UMinho Software Engineering Study Plan, available [here](https://www.uminho.pt/PT/ensino/oferta-educativa/_layouts/15/UMinho.PortalUM.UI/Pages/CatalogoCursoDetail.aspx?itemId=4346&catId=13). However, you should not have to change it. 126 | 127 | > **Note** 128 | > The `building` parameter should be composed of `CP` + `` **only for buildings 1, 2 and 3**. For the remaining buildings it's simply composed of the building number. 129 | 130 | > **Note** 131 | > The `filterId` is composed of: ``. 132 | > For example: `221` (Bases de Dados). 133 | > Check out the `filterId` of each class by searching its name on `shifts.json` or checking the `id` parameter in `filters.json`. 134 | 135 | Check out this example: 136 | 137 | ```json 138 | { 139 | "id": 14296, 140 | "title": "Laboratórios de Informática II", 141 | "theoretical": false, 142 | "shift": "PL8", 143 | "building": "CP2", 144 | "room": "1.09", 145 | "day": 3, 146 | "start": "08:00", 147 | "end": "10:00", 148 | "filterId": 123 149 | } 150 | ``` 151 | 152 | > **Note** 153 | > You can get a local preview of your changes by running the project on your machine, follow the [Contributing Guide](CONTRIBUTING.md) to know more. 154 | 155 | ## 🛫 Stage, commit and push your changes 156 | 157 | **1.** Stage your changes: 158 | 159 | ``` 160 | git add . 161 | ``` 162 | 163 | **2.** Commit your changes: 164 | 165 | ``` 166 | git commit -m "" 167 | ``` 168 | 169 | **3.** Push your changes to your forked repository: 170 | 171 | ``` 172 | git push 173 | ``` 174 | 175 | ## 🚀 Create a pull request 176 | 177 | **1.** Go to your forked repository on the GitHub website 178 | 179 | **2.** Select the branch you created from the dropdown menu 180 | 181 | **3.** Click on **Pull request** 182 | 183 | **4.** Add a convenient title and description to your pull request 184 | 185 | **5.** Assign a contributor for review 186 | 187 | ## 🎉 You're done! 188 | 189 | If everything checks out, your pull request will be reviewed and approved shortly. 190 | 191 | Visit the [Calendarium](https://calendario.cesium.di.uminho.pt/) website and check out your changes! 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | Calendarium 7 | 8 | 9 |

10 | 11 | [netlify-status]: https://app.netlify.com/sites/cesium-calendarium/deploys 12 | 13 | > 📅 Calendar with special events, due dates and week schedule 14 | 15 | Exams, projects, events and schedules. Your hub to everything LEI, MEI or even MIEI! 16 | 17 | Anything out of place? Give us your feedback using the [Suggestions Form](https://forms.gle/C2uxuUKqoeqMWfcZ6)! 18 | 19 | You can also fix it yourself following our [Helping Guide](HELPING_GUIDE.md) ;) 20 | 21 | ## 🤝 Contributing 22 | 23 | When contributing to this repository, please first discuss the change you wish to make via discussions, issue, email, or any other method with the owners of this repository before making a change. 24 | 25 | Please note we have a [Code of Conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 26 | 27 | We have a [Contributing Guide](CONTRIBUTING.md) to help you getting started. 28 | 29 | ## 📑 Features 30 | 31 | > Here's a quick view of the features you can expect 32 | 33 | ### Multiple calendar views 34 | 35 | - Day 36 | - Week 37 | - Month 38 | 39 | ### Event & Schedule filtering 40 | 41 | See only the activities that matter to you 42 | 43 | ### Clear Choices 44 | 45 | New schedule? New subject? Clear all your choices with one click and start again 46 | 47 | ### Share 48 | 49 | Share your schedule or events with another device or with your friends 50 | 51 | ### Export 52 | 53 | Export your calendar or schedule to your favorite calendar app 54 | 55 | ### Notifications 56 | 57 | Get updated on the latest changes to the platform and know when new information is added 58 | 59 | ### Color Themes 60 | 61 | Customize your calendar view with a choice of themes or create your own! 62 | 63 | ### Appearance 64 | 65 | Choose between beautiful dark and light modes! 66 | 67 | ## 🔌 Calendar Export API 68 | 69 | > Understand how our Export API works, and what you can do with it 70 | 71 | ### API Endpoints 72 | 73 | There are two endpoints you can work with: `/api/export/events` and `/api/export/schedule`. 74 | 75 | **1. `/events`** 76 | 77 | This endpoint is responsible for generating a .ics (iCal) file containing events from the "Events" page. It should receive a list of subjects in their short form, similarly to how subjects are displayed in the filters of Calendarium, joined by `&`. 78 | 79 | Check out this example, where we ask the API to generate a file containing the events from the 2nd year / 2nd semester subjects of LEI: 80 | 81 | ``` 82 | https://calendario.cesium.di.uminho.pt/api/export/events?BD&IO&MNOnL&POO&RC&SO 83 | ``` 84 | 85 | > **Note** 86 | > Only the events relevant to the current academic year will be exported. A request with wrongly formatted parameters or subjects that don't exist, will be answered with a "400 Invalid Request" error message. 87 | 88 | **2. `/schedule`** 89 | 90 | This endpoint is responsible for generating a .ics (iCal) file containing shifts from the "Schedule" page, which will have a weekly recurrence rule. It should receive a list of subject-shift pairs, joined by `&`. Similarly to what happens in `/events`, the subject should be represented in its short form. As for the shift, it should be in accordance with the shifts displayed in the filters of Calendarium. 91 | 92 | Check out this example, where we ask the API to generate a file containing a possible schedule for the 3rd year / 1st semester of LEI: 93 | 94 | ``` 95 | https://calendario.cesium.di.uminho.pt/api/export/schedule?CP=T1&CP=TP1&CC=T1&CC=PL1&DSS=T1&DSS=PL1&IA=T1&IA=PL1&LI4=T1&LI4=OT1&SD=T1&SD=PL1 96 | ``` 97 | 98 | > **Note** 99 | > A request with wrongly formatted parameters, or subjects and shifts that don't exist, will be answered with a "400 Invalid Request" error message. 100 | 101 | ### Setting a Time Period for Shifts 102 | 103 | Additionally, it's possible to set an interval in `/schedule` for when a schedule should start and end. This means that you can set a start date and an end date which will delimit when the shifts from your schedule will start and stop showing up on your calendar. For this, you can simply use the parameters `start` and `end`. By default, your schedule will show up on your calendar from the current week onwards, repeating forever. 104 | 105 | Check out this example, where we set the above schedule to start on 11/09/2023 and end on 08/08/2024: 106 | 107 | ``` 108 | https://calendario.cesium.di.uminho.pt/api/export/schedule?CP=T1&CP=TP1&CC=T1&CC=PL1&DSS=T1&DSS=PL1&IA=T1&IA=PL1&LI4=T1&LI4=OT1&SD=T1&SD=PL1&start=2023-09-11&end=2024-08-08 109 | ``` 110 | 111 | _Note that you can define **just the start, just the end, both or neither**. The parameter you omit will assume the default behaviour._ 112 | -------------------------------------------------------------------------------- /assets/calendarium-banner-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesium/calendarium/77157e834bf296324a860ff205cda21f65eb48a4/assets/calendarium-banner-dark.png -------------------------------------------------------------------------------- /assets/calendarium-banner-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesium/calendarium/77157e834bf296324a860ff205cda21f65eb48a4/assets/calendarium-banner-light.png -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cesium/calendarium/77157e834bf296324a860ff205cda21f65eb48a4/bun.lockb -------------------------------------------------------------------------------- /components/CalendarExportModal/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./CalendarExportModal"; 2 | -------------------------------------------------------------------------------- /components/ClearSelectionButton/ClearSelectionButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAppInfo } from "../../contexts/AppInfoProvider"; 2 | import ConfirmPopUpButton from "../ConfirmPopUpButton"; 3 | 4 | const ClearSelectionButton = ({ 5 | isSettings, 6 | clearSelection, 7 | }: { 8 | isSettings: boolean; 9 | clearSelection: () => void; 10 | }) => { 11 | let classNameData = `h-10 w-10 rounded-xl p-2 font-medium leading-3 text-error/50 shadow-md ring-1 ring-neutral-200/50 transition-all duration-300 hover:text-error hover:shadow-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-neutral-800/70 dark:text-red-400/60 dark:ring-neutral-400/20 dark:hover:text-red-400 ${ 12 | isSettings && 13 | "cursor-not-allowed hover:text-error/50 dark:hover:text-red-400/60" 14 | }`; 15 | 16 | const info = useAppInfo(); 17 | 18 | return ( 19 | clearSelection()} 26 | onCancel={() => {}} 27 | okText="Clear" 28 | showCancel={false} 29 | icon={} 30 | buttonProps={{ 31 | disabled: isSettings, 32 | type: "button", 33 | title: "Clear", 34 | className: classNameData, 35 | "data-umami-event": "clear-selection-button", 36 | "data-umami-event-type": info.isEvents ? "events" : "shifts", 37 | }} 38 | > 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default ClearSelectionButton; 45 | -------------------------------------------------------------------------------- /components/ClearSelectionButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ClearSelectionButton"; 2 | -------------------------------------------------------------------------------- /components/ConfirmPopUpButton/ConfirmPopUpButton.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, ReactNode } from "react"; 2 | import { Menu, Transition } from "@headlessui/react"; 3 | 4 | type ConfirmPopUpButtonProps = { 5 | children: ReactNode; 6 | title: string; 7 | description: string; 8 | onConfirm: () => void; 9 | onCancel: () => void; 10 | okText?: string; 11 | cancelText?: string; 12 | showCancel?: boolean; 13 | icon?: ReactNode; 14 | buttonProps?: any; 15 | }; 16 | 17 | const ConfirmPopUpButton = ({ 18 | children, 19 | title, 20 | description, 21 | onConfirm, 22 | onCancel, 23 | okText = "Ok", 24 | cancelText = "Cancel", 25 | showCancel = true, 26 | icon = , 27 | buttonProps = {}, 28 | }: ConfirmPopUpButtonProps) => { 29 | return ( 30 | 31 | {children} 32 | 41 |
42 |
43 | {icon} 44 | {title} 45 |
46 |
{description}
47 |
48 | {showCancel && ( 49 | 55 | )} 56 | 64 |
65 |
66 |
67 |
68 | ); 69 | }; 70 | 71 | export default ConfirmPopUpButton; 72 | -------------------------------------------------------------------------------- /components/ConfirmPopUpButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ConfirmPopUpButton"; 2 | -------------------------------------------------------------------------------- /components/CustomToolbar/CustomToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, ToolbarProps } from "react-big-calendar"; 2 | import { Fragment } from "react"; 3 | import { Menu, Transition } from "@headlessui/react"; 4 | 5 | import useWindowSize from "../../hooks/useWindowSize"; 6 | 7 | const MobileToolbar = ({ view, onView }) => { 8 | return ( 9 | 10 | 11 | {view === "day" ? ( 12 | 13 | ) : view === "week" ? ( 14 | 15 | ) : view === "month" ? ( 16 | 17 | ) : null} 18 | 19 | 28 | 29 |
30 | 31 | {({ active }) => ( 32 | 44 | )} 45 | 46 | 47 | {({ active }) => ( 48 | 60 | )} 61 | 62 | 63 | {({ active }) => ( 64 | 76 | )} 77 | 78 |
79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | const DesktopToolbar = ({ view, onView }) => { 86 | return ( 87 | <> 88 | 98 | 108 | 118 | 119 | ); 120 | }; 121 | 122 | const CustomToolbar = ({ label, onNavigate, view, onView }: ToolbarProps) => { 123 | const size = useWindowSize(); 124 | 125 | return ( 126 |
127 | 128 | 135 | 142 | 149 | 150 | {label} 151 | 152 | {size.width < 540 ? ( 153 | 154 | ) : ( 155 | 156 | )} 157 | 158 |
159 | ); 160 | }; 161 | 162 | export default CustomToolbar; 163 | -------------------------------------------------------------------------------- /components/CustomToolbar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./CustomToolbar"; 2 | -------------------------------------------------------------------------------- /components/DarkModeToggler/DarkModeToggler.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from "@headlessui/react"; 2 | import { useTheme } from "next-themes"; 3 | 4 | function classNames(...classes) { 5 | return classes.filter(Boolean).join(" "); 6 | } 7 | 8 | const DarkModeToggler = () => { 9 | const { theme, setTheme } = useTheme(); 10 | 11 | return ( 12 |
13 | 14 | 41 |
42 | ); 43 | }; 44 | 45 | export default DarkModeToggler; 46 | -------------------------------------------------------------------------------- /components/DarkModeToggler/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./DarkModeToggler"; 2 | -------------------------------------------------------------------------------- /components/EventFilters/EventFilters.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import "antd/dist/reset.css"; 3 | 4 | import FilterBlock from "../FilterBlock"; 5 | 6 | import { CheckBoxProps, Layer } from "../../types"; 7 | import { IFilterDTO, ISelectedFilterDTO } from "../../dtos"; 8 | import { useAppInfo } from "../../contexts/AppInfoProvider"; 9 | import * as mei_perfis from "../../data/mei_perfis"; 10 | 11 | type EventFiltersProps = { 12 | clearEvents: boolean; 13 | checked: ISelectedFilterDTO[]; 14 | setChecked: (obj: ISelectedFilterDTO[]) => void; 15 | }; 16 | 17 | const EventFilters = ({ 18 | clearEvents, 19 | checked, 20 | setChecked, 21 | }: EventFiltersProps) => { 22 | const info = useAppInfo(); 23 | const filters = info.filters as IFilterDTO[]; 24 | const handleFilters = info.handleFilters; 25 | 26 | useEffect(() => { 27 | const storedFiltersData: number[] = 28 | JSON.parse(localStorage.getItem("checked")) ?? []; 29 | const storedFilters: ISelectedFilterDTO[] = storedFiltersData.map((id) => ({ 30 | id, 31 | })); 32 | setChecked(storedFilters); 33 | handleFilters(storedFilters); 34 | }, [setChecked, handleFilters]); 35 | 36 | let event: IFilterDTO[][] = []; 37 | 38 | event[0] = filters.filter((f) => f.groupId === 1 && f.semester === 1); // 1st year 1st semester 39 | event[1] = filters.filter((f) => f.groupId === 1 && f.semester === 2); // 1st year 2nd semester 40 | event[2] = filters.filter((f) => f.groupId === 2 && f.semester === 1); // 2nd year 1st semester 41 | event[3] = filters.filter((f) => f.groupId === 2 && f.semester === 2); // 2nd year 2nd semester 42 | event[4] = filters.filter((f) => f.groupId === 3 && f.semester === 1); // 3rd year 1st semester 43 | event[5] = filters.filter((f) => f.groupId === 3 && f.semester === 2); // 3rd year 2nd semester 44 | 45 | event[6] = filters.filter((f) => f.groupId === 4 && f.semester === 1); // 4th year 1st semester 46 | event[7] = filters.filter((f) => f.groupId === 4 && f.semester === 2); // 4th year 2nd semester 47 | event[8] = filters.filter((f) => f.groupId === 5); // 5th year 48 | 49 | event[9] = filters.filter((f) => f.groupId === 0); // others 50 | 51 | // Converts all filter information into the universal format used by FilterBlock 52 | function getCheckBoxes(): CheckBoxProps[][] { 53 | const checkBoxes: CheckBoxProps[][] = []; 54 | 55 | let i: number; 56 | for (i = 0; i < event.length; i++) { 57 | const items: CheckBoxProps[] = event[i].map((e) => { 58 | const item: CheckBoxProps = { id: e.id, label: e.name }; 59 | return item; 60 | }); 61 | 62 | checkBoxes.push(items); 63 | } 64 | 65 | return checkBoxes; 66 | } 67 | 68 | const clearSelection = useCallback(() => { 69 | setChecked([]); 70 | info.handleFilters([]); 71 | localStorage.setItem("checked", JSON.stringify([])); 72 | }, [info, setChecked]); 73 | 74 | useEffect(() => { 75 | clearEvents && clearSelection(); 76 | }, [clearEvents, clearSelection]); 77 | 78 | const lei: Layer[] = [ 79 | { 80 | title: "1ˢᵗ year", 81 | sublayers: [ 82 | { 83 | title: "1ˢᵗ semester", 84 | checkboxes: getCheckBoxes()[0].slice(0, 5), 85 | sublayers: [ 86 | { 87 | title: "Opção UMinho", 88 | checkboxes: getCheckBoxes()[0].slice(5), 89 | }, 90 | ], 91 | }, 92 | { 93 | title: "2ⁿᵈ semester", 94 | checkboxes: getCheckBoxes()[1], 95 | }, 96 | ], 97 | }, 98 | { 99 | title: "2ⁿᵈ year", 100 | sublayers: [ 101 | { 102 | title: "1ˢᵗ semester", 103 | checkboxes: getCheckBoxes()[2], 104 | }, 105 | { 106 | title: "2ⁿᵈ semester", 107 | checkboxes: getCheckBoxes()[3], 108 | }, 109 | ], 110 | }, 111 | { 112 | title: "3ʳᵈ year", 113 | sublayers: [ 114 | { 115 | title: "1ˢᵗ semester", 116 | checkboxes: getCheckBoxes()[4], 117 | }, 118 | { 119 | title: "2ⁿᵈ semester", 120 | checkboxes: getCheckBoxes()[5], 121 | }, 122 | ], 123 | }, 124 | ]; 125 | 126 | const mei: Layer[] = [ 127 | { 128 | title: "4ᵗʰ year", 129 | sublayers: [ 130 | { 131 | title: "1ˢᵗ semester", 132 | checkboxes: getCheckBoxes()[6], 133 | }, 134 | { 135 | title: "2ⁿᵈ semester", 136 | sublayers: [ 137 | { 138 | title: "CA", 139 | checkboxes: getCheckBoxes()[7].filter((i) => 140 | mei_perfis.mei_ca.includes(i.label) 141 | ), 142 | }, 143 | { 144 | title: "CG", 145 | checkboxes: getCheckBoxes()[7].filter((i) => 146 | mei_perfis.mei_cg.includes(i.label) 147 | ), 148 | }, 149 | { 150 | title: "CSI", 151 | checkboxes: getCheckBoxes()[7].filter((i) => 152 | mei_perfis.mei_csi.includes(i.label) 153 | ), 154 | }, 155 | { 156 | title: "EA", 157 | checkboxes: getCheckBoxes()[7].filter((i) => 158 | mei_perfis.mei_ea.includes(i.label) 159 | ), 160 | }, 161 | { 162 | title: "EC", 163 | checkboxes: getCheckBoxes()[7].filter((i) => 164 | mei_perfis.mei_ec.includes(i.label) 165 | ), 166 | }, 167 | { 168 | title: "EI", 169 | checkboxes: getCheckBoxes()[7].filter((i) => 170 | mei_perfis.mei_ei.includes(i.label) 171 | ), 172 | }, 173 | { 174 | title: "EL", 175 | checkboxes: getCheckBoxes()[7].filter((i) => 176 | mei_perfis.mei_el.includes(i.label) 177 | ), 178 | }, 179 | { 180 | title: "SD", 181 | checkboxes: getCheckBoxes()[7].filter((i) => 182 | mei_perfis.mei_sd.includes(i.label) 183 | ), 184 | }, 185 | { 186 | title: "SDVM", 187 | checkboxes: getCheckBoxes()[7].filter((i) => 188 | mei_perfis.mei_sdvm.includes(i.label) 189 | ), 190 | }, 191 | { 192 | title: "SDW", 193 | checkboxes: getCheckBoxes()[7].filter((i) => 194 | mei_perfis.mei_sdw.includes(i.label) 195 | ), 196 | }, 197 | { 198 | title: "SI", 199 | checkboxes: getCheckBoxes()[7].filter((i) => 200 | mei_perfis.mei_si.includes(i.label) 201 | ), 202 | }, 203 | { 204 | title: "MFP", 205 | checkboxes: getCheckBoxes()[7].filter((i) => 206 | mei_perfis.mei_mfp.includes(i.label) 207 | ), 208 | }, 209 | { 210 | title: "RNG", 211 | checkboxes: getCheckBoxes()[7].filter((i) => 212 | mei_perfis.mei_rng.includes(i.label) 213 | ), 214 | }, 215 | ], 216 | }, 217 | ], 218 | }, 219 | { 220 | title: "5ᵗʰ year", 221 | checkboxes: getCheckBoxes()[8], 222 | }, 223 | ]; 224 | 225 | return ( 226 | <> 227 | {/* LEI */} 228 | 229 | {/* MEI */} 230 | 231 | 232 | ); 233 | }; 234 | 235 | export default EventFilters; 236 | -------------------------------------------------------------------------------- /components/EventFilters/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./EventFilters"; 2 | -------------------------------------------------------------------------------- /components/EventModal/EventModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Box, Typography, Fade, Backdrop } from "@mui/material"; 2 | 3 | import { IEventDTO } from "../../dtos"; 4 | 5 | type EventModalProps = { 6 | selectedEvent: IEventDTO; 7 | setInspectEvent: (boolean) => void; 8 | inspectEvent: boolean; 9 | }; 10 | 11 | function EventModal({ 12 | selectedEvent, 13 | setInspectEvent, 14 | inspectEvent, 15 | }: EventModalProps) { 16 | const start_date = new Date(selectedEvent.start).toLocaleDateString("pt", {}); 17 | 18 | const start_hour = new Date(selectedEvent.start).toLocaleTimeString("pt", { 19 | hour: "numeric", 20 | minute: "numeric", 21 | }); 22 | 23 | const end_date = new Date(selectedEvent.end).toLocaleDateString("pt", {}); 24 | 25 | const end_hour = new Date(selectedEvent.end).toLocaleTimeString("pt", { 26 | hour: "numeric", 27 | minute: "numeric", 28 | }); 29 | 30 | const handleModalClose = () => { 31 | setInspectEvent(false); 32 | }; 33 | 34 | const dateInfo = ( 35 | <> 36 | {" "} 37 | {start_date.localeCompare(end_date) 38 | ? `${start_date.slice(0, 5)} - ${end_date.slice(0, 5)}` 39 | : `${start_date.slice(0, 5)}`} 40 | 41 | ); 42 | 43 | const formattedLink = (link: string) => ( 44 | <> 45 | {selectedEvent.link.replace("https://", "").length > 30 46 | ? selectedEvent.link.replace("https://", "").split("/")[0] + "/..." 47 | : selectedEvent.link.replace("https://", "")} 48 | 49 | ); 50 | 51 | return ( 52 |
53 | 60 | 61 | 65 |
66 |
67 | 68 | {selectedEvent.title} 69 | 70 |
71 | 142 |
143 |
144 |
145 |
146 |
147 | ); 148 | } 149 | 150 | export default EventModal; 151 | -------------------------------------------------------------------------------- /components/EventModal/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./EventModal"; 2 | -------------------------------------------------------------------------------- /components/ExportButton/ExportButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import CalendarExportModal from "../CalendarExportModal"; 3 | import { useAppInfo } from "../../contexts/AppInfoProvider"; 4 | 5 | const ExportButton = () => { 6 | const [isModalOpen, setIsModalOpen] = useState(false); 7 | const info = useAppInfo(); 8 | 9 | return ( 10 |
11 | 20 | 21 | 22 |
23 | ); 24 | }; 25 | 26 | export default ExportButton; 27 | -------------------------------------------------------------------------------- /components/ExportButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ExportButton"; 2 | -------------------------------------------------------------------------------- /components/FilterBlock/FilterBlock.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, Collapse } from "antd"; 2 | import "antd/dist/reset.css"; 3 | import { Fragment, useEffect, useState } from "react"; 4 | import { CheckBox, CheckBoxProps, Layer } from "../../types"; 5 | import { ISelectedFilterDTO } from "../../dtos"; 6 | import { useAppInfo } from "../../contexts/AppInfoProvider"; 7 | 8 | type FilterBlockProps = { 9 | layers: Layer[]; 10 | checked: ISelectedFilterDTO[]; 11 | setChecked: (obj: ISelectedFilterDTO[]) => void; 12 | }; 13 | 14 | const Indicator = () => ( 15 |
16 | ); 17 | 18 | const RenderLayer = ({ 19 | layers, 20 | prevTitle = "", 21 | checked, 22 | setChecked, 23 | }: { 24 | layers: Layer[]; 25 | prevTitle?: string; 26 | checked: ISelectedFilterDTO[]; 27 | setChecked: (obj: ISelectedFilterDTO[]) => void; 28 | }) => { 29 | const { handleFilters } = useAppInfo(); 30 | 31 | const saveState = (key: string, value: unknown) => { 32 | localStorage.setItem(key, JSON.stringify(value)); 33 | }; 34 | 35 | const toggleItem = (id: number, shift?: string) => { 36 | const isShift = typeof shift !== "undefined"; 37 | const index = isShift 38 | ? checked.findIndex((item) => item.id === id && item.shift === shift) 39 | : checked.findIndex((item) => item.id === id); 40 | 41 | const updated = 42 | index === -1 43 | ? [...checked, isShift ? { id, shift } : { id }] 44 | : checked.filter((_, idx) => idx !== index); 45 | 46 | const storeUpdated = isShift ? updated : updated.map(({ id }) => id); 47 | 48 | setChecked(updated); 49 | handleFilters(updated); 50 | saveState(isShift ? "shifts" : "checked", storeUpdated); 51 | }; 52 | 53 | const isChecked = (id: number, shift?: string) => 54 | checked.some((item) => 55 | shift ? item.id === id && item.shift === shift : item.id === id 56 | ); 57 | 58 | const isGroupChecked = (items: CheckBox[]) => { 59 | return items.some(({ id, label, isShift }) => 60 | isChecked(id, isShift ? label : undefined) 61 | ); 62 | }; 63 | 64 | const toggleAll = (items: CheckBox[]) => { 65 | const allChecked = items.every(({ id, label, isShift }) => 66 | isChecked(id, isShift ? label : undefined) 67 | ); 68 | 69 | const updated = allChecked 70 | ? checked.filter((item) => !items.some(({ id }) => item.id === id)) 71 | : [ 72 | ...checked, 73 | ...items 74 | .filter( 75 | ({ id, label, isShift }) => 76 | !isChecked(id, isShift ? label : undefined) 77 | ) 78 | .map(({ id, label, isShift }) => 79 | isShift ? { id, shift: label } : { id } 80 | ), 81 | ]; 82 | 83 | const isShift = items[0].isShift; 84 | 85 | const storeUpdated = isShift ? updated : updated.map(({ id }) => id); 86 | 87 | setChecked(updated); 88 | handleFilters(updated); 89 | saveState(isShift ? "shifts" : "checked", storeUpdated); 90 | }; 91 | 92 | const isAllGroupChecked = (items: CheckBox[]) => { 93 | return items.every(({ id, label, isShift }) => 94 | isChecked(id, isShift ? label : undefined) 95 | ); 96 | }; 97 | 98 | const RenderCheckBoxList = ({ items }: { items: CheckBox[] }) => { 99 | return ( 100 |
101 | {items.length > 0 ? ( 102 | <> 103 | {items.length > 1 && ( 104 | toggleAll(items)} 106 | checked={isAllGroupChecked(items)} 107 | indeterminate={ 108 | isGroupChecked(items) && !isAllGroupChecked(items) 109 | } 110 | className="border-b pb-2" 111 | > 112 | Select All 113 | 114 | )} 115 |
116 | {items.map(({ id, label, isShift }) => ( 117 | toggleItem(id, isShift ? label : undefined)} 120 | checked={isChecked(id, isShift ? label : undefined)} 121 | > 122 | {label} 123 | 124 | ))} 125 |
126 | 127 | ) : ( 128 |

Information not available.

129 | )} 130 |
131 | ); 132 | }; 133 | 134 | const getSublayerCheckboxes = (layer: Layer) => { 135 | if (!layer.sublayers) return layer.checkboxes ?? []; 136 | return layer.sublayers.reduce( 137 | (acc, sl) => [...acc, ...getSublayerCheckboxes(sl)], 138 | [] 139 | ); 140 | }; 141 | 142 | const showIndicator = (layer: Layer) => { 143 | return isGroupChecked([ 144 | ...(layer.checkboxes ?? []), 145 | ...getSublayerCheckboxes(layer), 146 | ]); 147 | }; 148 | 149 | if (layers.length === 0) 150 | return

Information not available.

; 151 | 152 | return ( 153 | 158 | {layers.map((layer) => ( 159 | 160 | {showIndicator(layer) && } 161 | 162 | {layer.checkboxes && ( 163 | 164 | )} 165 | {layer.sublayers && ( 166 | 171 | )} 172 | 173 | 174 | ))} 175 | 176 | ); 177 | }; 178 | 179 | const FilterBlock = ({ layers, checked, setChecked }: FilterBlockProps) => { 180 | return ( 181 |
182 | 187 |
188 | ); 189 | }; 190 | 191 | export default FilterBlock; 192 | -------------------------------------------------------------------------------- /components/FilterBlock/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./FilterBlock"; 2 | -------------------------------------------------------------------------------- /components/Install/Install.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | import { Modal, Box, Fade, Backdrop, Typography } from "@mui/material"; 4 | 5 | import { Collapse } from "antd"; 6 | import { BeforeInstallPromptEvent } from "../../types"; 7 | 8 | type InstallProps = { 9 | installPwaPrompt: BeforeInstallPromptEvent; 10 | }; 11 | 12 | const Install = ({ installPwaPrompt }: InstallProps) => { 13 | const [isModalOpen, setIsModalOpen] = useState(false); 14 | 15 | const installPwa = (evt) => { 16 | evt.preventDefault(); 17 | if (!installPwaPrompt) { 18 | setIsModalOpen(true); 19 | } else { 20 | installPwaPrompt.prompt(); 21 | } 22 | }; 23 | 24 | return ( 25 |
26 | 29 | 36 | 37 | setIsModalOpen(false)} 40 | closeAfterTransition 41 | slots={{ backdrop: Backdrop }} 42 | slotProps={{ backdrop: { timeout: 400 } }} 43 | > 44 | 45 | 52 |
53 | 57 | Install Calendarium 58 | 59 |
60 | You can 61 | install Calendarium as a{" "} 62 | 68 | PWA {"("}Progressive Web App{")"} 69 | {" "} 70 | on your device. 71 |
72 | 76 | 77 |
    78 |
  1. 79 | Open Chrome. 80 |
  2. 81 |
  3. Navigate to the app URL.
  4. 82 |
  5. 83 | Tap the three dots in the 84 | top right. 85 |
  6. 86 |
  7. 87 | Choose {'"'}Install{'"'} or {'"'}Add to Home Screen{'"'}. 88 |
  8. 89 |
  9. Follow the prompts to install the app.
  10. 90 |
91 |
92 | 93 |
    94 |
  1. 95 | Open Safari. 96 |
  2. 97 |
  3. Navigate to the app URL.
  4. 98 |
  5. 99 | Tap the Share button at the 100 | bottom. 101 |
  6. 102 |
  7. 103 | Choose {'"'}Add to Home Screen.{'"'} 104 |
  8. 105 |
  9. Follow the prompts to install the app.
  10. 106 |
107 |
108 | 109 |
    110 |
  1. Open your browser.
  2. 111 |
  3. Navigate to the app URL.
  4. 112 |
  5. Look for the browser{"'"}s menu or settings.
  6. 113 |
  7. 114 | Choose the option related to installing or adding to the 115 | home screen. 116 |
  8. 117 |
  9. Follow the prompts to install the app.
  10. 118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 | ); 127 | }; 128 | 129 | export default Install; 130 | -------------------------------------------------------------------------------- /components/Install/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Install"; 2 | -------------------------------------------------------------------------------- /components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { useState, ReactNode, useEffect } from "react"; 2 | import Link from "next/link"; 3 | import Sidebar from "../Sidebar"; 4 | import Notifications from "../Notifications"; 5 | import { useTheme } from "next-themes"; 6 | import Head from "next/head"; 7 | import Image from "next/image"; 8 | import { AppInfoProvider } from "../../contexts/AppInfoProvider"; 9 | import MoreButton from "../MoreButton"; 10 | import { ISelectedFilterDTO } from "../../dtos"; 11 | 12 | interface ILayoutProps { 13 | children: ReactNode; 14 | isEvents: boolean; 15 | filters: any; 16 | handleFilters: (filters: ISelectedFilterDTO[]) => void; 17 | fetchTheme: () => void; 18 | handleData?: (_: boolean) => void; 19 | } 20 | 21 | const Layout = ({ 22 | children, 23 | isEvents, 24 | filters, 25 | handleFilters, 26 | fetchTheme, 27 | handleData, 28 | }: ILayoutProps) => { 29 | const [isOpen, setIsOpen] = useState(false); 30 | const hamburgerLine = `h-1 w-6 my-0.5 rounded-full bg-black transition ease transform duration-300 dark:bg-neutral-200 bg-neutral-900`; 31 | const [image, setImage] = useState("/calendarium-light.svg"); 32 | const { resolvedTheme } = useTheme(); 33 | 34 | useEffect(() => { 35 | setImage( 36 | resolvedTheme === "dark" 37 | ? "/calendarium-dark.svg" 38 | : "/calendarium-light.svg" 39 | ); 40 | }, [resolvedTheme]); 41 | 42 | return ( 43 | 53 |
54 | 55 | {/* Status Bar configuration for Android devices */} 56 | 60 | {/* Status Bar configuration for IOS devices */} 61 | 65 | 66 | {/* Open/Close Sidebar Button */} 67 | 91 | 92 | {/* Notification Badges */} 93 | 94 | 95 | {/* Calendarium Logo */} 96 |
97 |
98 | 99 | Calendarium Logo 106 | 107 |
108 |
109 | 110 | {/* Sidebar */} 111 |
112 | 113 |
114 | 115 | {/* More Button */} 116 | 117 | 118 | {/* Children */} 119 |
{children}
120 |
121 |
122 | ); 123 | }; 124 | 125 | export default Layout; 126 | -------------------------------------------------------------------------------- /components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Layout"; 2 | -------------------------------------------------------------------------------- /components/Layout/layout.module.scss: -------------------------------------------------------------------------------- 1 | .buttonBug { 2 | width: 3rem; 3 | height: 3rem; 4 | margin: auto; 5 | font-family: Inter; 6 | background: rgb(133, 165, 255); 7 | border-radius: 0.75rem; 8 | box-sizing: border-box; 9 | color: #ffffff; 10 | cursor: pointer; 11 | font-size: 16px; 12 | font-weight: 500; 13 | line-height: 24px; 14 | opacity: 1; 15 | outline: 0 solid transparent; 16 | padding: 10px 16px; 17 | user-select: none; 18 | -webkit-user-select: none; 19 | touch-action: manipulation; 20 | word-break: break-word; 21 | border: 0; 22 | transition: 150ms linear; 23 | box-shadow: 0 0px 10px rgba(0, 0, 0, 0.2); 24 | 25 | div { 26 | text-align: right; 27 | } 28 | 29 | p { 30 | position: absolute; 31 | left: 0; 32 | margin-left: 1.1rem; 33 | opacity: 0; 34 | transition: opacity 100ms; 35 | } 36 | 37 | &:hover { 38 | box-shadow: rgb(133, 165, 255) 0 2px 12px 0px; 39 | width: 8.5rem; 40 | 41 | p { 42 | opacity: 1; 43 | transition-delay: 200ms; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /components/MoreButton/MoreButton.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { Menu, Transition } from "@headlessui/react"; 3 | import { useAppInfo } from "../../contexts/AppInfoProvider"; 4 | 5 | const MoreButton = ({ isOpen }: { isOpen: boolean }) => { 6 | const info = useAppInfo(); 7 | 8 | return ( 9 |
14 | 15 | 16 | 17 | 18 | 27 | 28 |
29 | 30 | {({ active }) => ( 31 | 39 | )} 40 | 41 | 42 | {({ active }) => ( 43 | 64 | )} 65 | 66 |
67 |
68 |
69 |
70 |
71 | ); 72 | }; 73 | 74 | export default MoreButton; 75 | -------------------------------------------------------------------------------- /components/MoreButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./MoreButton"; 2 | -------------------------------------------------------------------------------- /components/NavigationPane/NavigationPane.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useRouter } from "next/router"; 3 | 4 | const NavigationPane = () => { 5 | const { asPath } = useRouter(); 6 | 7 | return ( 8 |
9 |
10 |
11 | 12 | 18 | EVENTS 19 | 20 | 21 | 22 | 28 | SCHEDULE 29 | 30 | 31 |
32 |
33 |
34 | ); 35 | }; 36 | 37 | export default NavigationPane; 38 | -------------------------------------------------------------------------------- /components/NavigationPane/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./NavigationPane"; 2 | -------------------------------------------------------------------------------- /components/Notifications/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Notifications"; 2 | -------------------------------------------------------------------------------- /components/ScheduleFilters/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ScheduleFilters"; 2 | -------------------------------------------------------------------------------- /components/Settings/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { IFilterDTO } from "../../dtos"; 2 | 3 | import Themes from "../Themes"; 4 | import Install from "../Install"; 5 | import DarkModeToggler from "../DarkModeToggler"; 6 | import { BeforeInstallPromptEvent } from "../../types"; 7 | 8 | type SettingsProps = { 9 | isOpen: boolean; 10 | setIsOpen: (isOpen: boolean) => void; 11 | installPwaPrompt: BeforeInstallPromptEvent; 12 | }; 13 | 14 | const Settings = ({ isOpen, setIsOpen, installPwaPrompt }: SettingsProps) => { 15 | return ( 16 |
17 | {/* Title */} 18 |
Settings
19 |
20 | {/* Configs */} 21 | 22 | 23 | 24 |
25 | ); 26 | }; 27 | 28 | export default Settings; 29 | -------------------------------------------------------------------------------- /components/Settings/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Settings"; 2 | -------------------------------------------------------------------------------- /components/ShareButton/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import ShareModal from "../ShareModal"; 3 | import { ISelectedFilterDTO } from "../../dtos"; 4 | import { useAppInfo } from "../../contexts/AppInfoProvider"; 5 | 6 | type ShareButtonProps = { 7 | setChecked: (obj: ISelectedFilterDTO[]) => void; 8 | }; 9 | 10 | const ShareButton = ({ setChecked }: ShareButtonProps) => { 11 | const info = useAppInfo(); 12 | const [isModalOpen, setIsModalOpen] = useState(false); 13 | 14 | return ( 15 |
16 | 25 | 26 | 31 |
32 | ); 33 | }; 34 | 35 | export default ShareButton; 36 | -------------------------------------------------------------------------------- /components/ShareButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ShareButton"; 2 | -------------------------------------------------------------------------------- /components/ShareModal/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ShareModal"; 2 | -------------------------------------------------------------------------------- /components/ShiftModal/ShiftModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Box, Typography, Fade, Backdrop } from "@mui/material"; 2 | 3 | import { IShiftDTO } from "../../dtos"; 4 | 5 | type ShiftModalProps = { 6 | selectedShift: IShiftDTO; 7 | setInspectShift: (boolean) => void; 8 | inspectShift: boolean; 9 | shifts: IShiftDTO[]; 10 | }; 11 | 12 | function ShiftModal({ 13 | selectedShift, 14 | setInspectShift, 15 | inspectShift, 16 | shifts, 17 | }: ShiftModalProps) { 18 | const start_hour = new Date(selectedShift.start).toLocaleTimeString("pt", { 19 | hour: "numeric", 20 | minute: "numeric", 21 | }); 22 | 23 | const end_hour = () => { 24 | const eh = new Date(selectedShift.end); 25 | eh.setMinutes(eh.getMinutes() + 1); // add 1 minute to compensate, refer to the comment on schedule.tsx 26 | return eh.toLocaleTimeString("pt", { 27 | hour: "numeric", 28 | minute: "numeric", 29 | }); 30 | }; 31 | 32 | const handleModalClose = () => { 33 | setInspectShift(false); 34 | }; 35 | 36 | const ano = String(selectedShift.filterId)[0]; 37 | const semestre = String(selectedShift.filterId)[1]; 38 | 39 | const name = shifts.find((shift) => shift.id === selectedShift.id).title; 40 | 41 | return ( 42 |
43 | 50 | 51 | 55 |
56 |
57 | 58 | {name} 59 | 60 |
61 | 84 |
85 |
86 |
87 |
88 |
89 | ); 90 | } 91 | 92 | export default ShiftModal; 93 | -------------------------------------------------------------------------------- /components/ShiftModal/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ShiftModal"; 2 | -------------------------------------------------------------------------------- /components/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import Link from "next/link"; 3 | import Image from "next/image"; 4 | import EventFilters from "../EventFilters"; 5 | import ScheduleFilters from "../ScheduleFilters"; 6 | import Settings from "../Settings"; 7 | import ExportButton from "../ExportButton"; 8 | import ClearSelectionButton from "../ClearSelectionButton"; 9 | import NavigationPane from "../NavigationPane"; 10 | import ShareButton from "../ShareButton"; 11 | import { BeforeInstallPromptEvent } from "../../types"; 12 | import { useAppInfo } from "../../contexts/AppInfoProvider"; 13 | import { ISelectedFilterDTO } from "../../dtos"; 14 | 15 | type SidebarProps = { 16 | isOpen?: boolean; 17 | setIsOpen?: (isOpen: boolean) => void; 18 | }; 19 | 20 | const Sidebar = ({ isOpen, setIsOpen }: SidebarProps) => { 21 | const [isSettings, setIsSettings] = useState(false); 22 | const [clear, setClear] = useState(false); 23 | const [checked, setChecked] = useState([]); 24 | const [promptInstall, setPromptInstall] = 25 | useState(null); 26 | const [animateRefresh, setAnimateRefresh] = useState(false); 27 | 28 | function clearSelection() { 29 | setClear(true); 30 | setTimeout(() => setClear(false), 300); 31 | } 32 | 33 | function refresh() { 34 | const lastUpdate: Date = 35 | new Date(localStorage.getItem("lastUpdateEvents")) || new Date(); // current date if lastUpdate is null 36 | const now: Date = new Date(); 37 | const diff: number = now.getTime() - lastUpdate.getTime(); // difference in milliseconds 38 | const diffMin: number = diff / (1000 * 60); // difference in minutes 39 | if (diffMin >= 1) info.handleData(true); // only fetch data if last update was more than 1 minute ago 40 | setAnimateRefresh(true); 41 | setTimeout(() => { 42 | setAnimateRefresh(false); 43 | setIsOpen(false); 44 | }, 1000); 45 | } 46 | 47 | useEffect(() => { 48 | const handler = (e) => { 49 | e.preventDefault(); 50 | setPromptInstall(e); 51 | }; 52 | window.addEventListener("beforeinstallprompt", handler); 53 | 54 | return () => window.removeEventListener("transitionend", handler); 55 | }, []); 56 | 57 | const sidebar = `lg:w-96 lg:block lg:translate-x-0 lg:h-full h-mobile lg:shadow-md lg:border-r dark:border-neutral-400/30 w-full absolute overflow-y-auto overflow-x-hidden lg:overflow-y-auto p-4 sm:p-6 bg-white dark:bg-neutral-900 z-10 transition ease transform duration-300`; 58 | 59 | const info = useAppInfo(); 60 | 61 | return ( 62 | 151 | ); 152 | }; 153 | 154 | export default Sidebar; 155 | -------------------------------------------------------------------------------- /components/Sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Sidebar"; 2 | -------------------------------------------------------------------------------- /components/Themes/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Themes"; 2 | -------------------------------------------------------------------------------- /contexts/AppInfoProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { IFilterDTO, ISelectedFilterDTO } from "../dtos"; 3 | 4 | interface AppInfoContextData { 5 | isEvents: boolean; 6 | filters: number[] | IFilterDTO[]; 7 | handleFilters: (filters: ISelectedFilterDTO[]) => void; 8 | fetchTheme: () => void; 9 | image: string; 10 | handleData: (_: boolean) => void; 11 | } 12 | 13 | const AppContext = createContext(undefined); 14 | 15 | export function AppInfoProvider({ 16 | children, 17 | data, 18 | }: { 19 | children: React.ReactNode; 20 | data: AppInfoContextData; 21 | }) { 22 | return {children}; 23 | } 24 | 25 | export function useAppInfo() { 26 | const context = useContext(AppContext); 27 | if (context === undefined) { 28 | throw new Error("useAppInfo must be used within a AppInfoProvider"); 29 | } 30 | return context; 31 | } 32 | -------------------------------------------------------------------------------- /data/mei_perfis.ts: -------------------------------------------------------------------------------- 1 | export const mei_ca = ["CHED", "ADGD", "SAD"]; 2 | export const mei_cg = ["VI", "VTR", "VCPI"]; 3 | export const mei_csi = ["TS", "EC", "ES"]; 4 | export const mei_ea = ["SIC", "AA", "ABD"]; 5 | export const mei_ec = ["MD", "BD NoSQL", "AISBD"]; 6 | export const mei_ei = ["GSR", "IRIP", "QSI"]; 7 | export const mei_el = ["EG", "RPCW", "SPLN"]; 8 | export const mei_sd = ["TF", "SDGE", "PSD"]; 9 | export const mei_sdvm = ["TDS", "MES", "EES"]; 10 | export const mei_sdw = ["SETCD", "CIAD", "ACAD"]; 11 | export const mei_si = ["SA", "AProf", "ASM"]; 12 | export const mei_mfp = ["VF", "PCF", "CSI"]; 13 | export const mei_rng = ["RFM", "RDS", "NPR"]; 14 | -------------------------------------------------------------------------------- /dtos/index.ts: -------------------------------------------------------------------------------- 1 | export interface IFilterDTO { 2 | id: number; 3 | name: string; 4 | groupId: number; 5 | semester: number; 6 | shifts?: string[]; 7 | } 8 | 9 | export interface ISelectedFilterDTO { 10 | id: number; 11 | shift?: string; 12 | } 13 | 14 | export interface IEventDTO { 15 | title: string; 16 | place: string; 17 | link: string; 18 | start: string | Date; 19 | end: string | Date; 20 | groupId: number; 21 | filterId: number; 22 | } 23 | 24 | export interface IShiftDTO { 25 | id: number; 26 | title: string; 27 | theoretical: boolean; 28 | shift: string; 29 | building: string; 30 | room: string; 31 | day: number; 32 | start: string; 33 | end: string; 34 | filterId: number; 35 | } 36 | 37 | export interface INotDTO { 38 | type: string; 39 | description: string; 40 | date: string; 41 | } 42 | -------------------------------------------------------------------------------- /hooks/useColorTheme.ts: -------------------------------------------------------------------------------- 1 | import { SetStateAction, useCallback, useState, useEffect } from "react"; 2 | import { SubjectColor } from "../types"; 3 | import { IFormatedShift } from "../pages/schedule"; 4 | import { IEventDTO } from "../dtos"; 5 | import { IFilterDTO } from "../dtos"; 6 | 7 | const DEFAULT_THEME = "Modern"; 8 | export const DEFAULT_COLORS = [ 9 | "#ed7950", // cesium 10 | "#4BC0D9", // 1st year 11 | "#7b54f0", // 2nd year 12 | "#f0547b", // 3rd year 13 | "#5ac77b", // 4th year 14 | "#395B50", // 5th year 15 | "#b70a0a", // uminho 16 | "#3408fd", // sei 17 | "#642580", // coderdojo 18 | "#FF0000", // join 19 | "#1B69EE", // jordi 20 | "#FF499E", // codeweek 21 | "#66B22E", // bugsbyte 22 | ]; 23 | 24 | export function reduceOpacity(hexColor) { 25 | // Convert HEX color code to RGBA color code 26 | let r = parseInt(hexColor.slice(1, 3), 16); 27 | let g = parseInt(hexColor.slice(3, 5), 16); 28 | let b = parseInt(hexColor.slice(5, 7), 16); 29 | let a = 0.25; // 25% opacity 30 | let rgbaColor = `rgba(${r}, ${g}, ${b}, ${a})`; 31 | 32 | return rgbaColor; 33 | } 34 | 35 | function mergeColors(colors: string[]) { 36 | let merged = [...colors]; 37 | for (let i = 0; i < DEFAULT_COLORS.length; i++) { 38 | if (merged[i] === undefined) merged[i] = DEFAULT_COLORS[i]; 39 | } 40 | return merged; 41 | } 42 | 43 | function initializeSubjectColors(filters: IFilterDTO[]) { 44 | const newSubjectColors: SubjectColor[] = []; 45 | filters.forEach((f) => { 46 | newSubjectColors.push({ 47 | filterId: f.id, 48 | color: DEFAULT_COLORS[f.groupId], 49 | }); 50 | }); 51 | return newSubjectColors; 52 | } 53 | 54 | function mergeSubjectColors(a: SubjectColor[], b: SubjectColor[]) { 55 | const merged = [...a]; 56 | b.forEach((bColor) => { 57 | if (!merged.find((aColor) => aColor.filterId === bColor.filterId)) { 58 | merged.push(bColor); 59 | } 60 | }); 61 | return merged; 62 | } 63 | 64 | function getDifferentSubjectColors(a: SubjectColor[], b: SubjectColor[]) { 65 | const different = []; 66 | a.forEach((aColor) => { 67 | if ( 68 | !b.find( 69 | (bColor) => 70 | bColor.filterId === aColor.filterId && bColor.color === aColor.color 71 | ) 72 | ) { 73 | different.push(aColor); 74 | } 75 | }); 76 | return different; 77 | } 78 | 79 | const fetchTheme = ( 80 | setTheme: (value: SetStateAction) => void, 81 | setOpacity: (value: SetStateAction) => void, 82 | setSubjectColors: (value: SetStateAction) => void, 83 | filters: IFilterDTO[] 84 | ) => { 85 | let themeLS = localStorage.getItem("theme"); 86 | const colorsLS = localStorage.getItem("colors"); 87 | const opacityLS = localStorage.getItem("opacity"); 88 | const subjectColorsLS: SubjectColor[] = 89 | JSON.parse(localStorage.getItem("subjectColors")) ?? []; 90 | 91 | // make sure only customized subject colors are saved 92 | localStorage.setItem( 93 | "subjectColors", 94 | JSON.stringify( 95 | getDifferentSubjectColors( 96 | subjectColorsLS, 97 | initializeSubjectColors(filters) 98 | ) 99 | ) 100 | ); 101 | 102 | // error proof checks 103 | colorsLS && 104 | colorsLS.split(",").length !== DEFAULT_COLORS.length && 105 | localStorage.setItem("colors", mergeColors(colorsLS.split(",")).join(",")); 106 | !themeLS && localStorage.setItem("theme", "Modern"); 107 | 108 | if (themeLS !== "Modern" && themeLS !== "Classic" && themeLS !== "Custom") { 109 | localStorage.setItem("theme", "Modern"); 110 | themeLS = "Modern"; 111 | } 112 | 113 | setTheme(themeLS); 114 | opacityLS ? setOpacity(opacityLS === "true") : setOpacity(true); 115 | setSubjectColors( 116 | // merge the customized subject colors with the default ones to create a complete list 117 | mergeSubjectColors(subjectColorsLS, initializeSubjectColors(filters)) 118 | ); 119 | }; 120 | 121 | function getDefaultColor(event: IFormatedShift | IEventDTO): string { 122 | if ((event as IFormatedShift).id !== undefined) { 123 | return DEFAULT_COLORS[String((event as IFormatedShift).filterId)[0]]; 124 | } 125 | if ((event as IEventDTO).groupId !== undefined) { 126 | return DEFAULT_COLORS[(event as IEventDTO).groupId]; 127 | } 128 | } 129 | 130 | // note: returns the default color if it was not found in the subjectColors array 131 | function getSubjectColor( 132 | event: IFormatedShift | IEventDTO, 133 | subjectColors: SubjectColor[] 134 | ) { 135 | const color = subjectColors.find( 136 | (sc) => sc.filterId === event.filterId 137 | )?.color; 138 | return color ? color : getDefaultColor(event); 139 | } 140 | 141 | function getBgColor( 142 | event: IFormatedShift | IEventDTO, 143 | theme: string, 144 | opacity: boolean, 145 | subjectColors: SubjectColor[] 146 | ) { 147 | let color: string = "#000000"; 148 | 149 | if (theme === "Modern") color = reduceOpacity(getDefaultColor(event)); 150 | else if (theme === "Classic") color = getDefaultColor(event); 151 | else if (theme === "Custom") { 152 | opacity 153 | ? (color = reduceOpacity(getSubjectColor(event, subjectColors))) 154 | : (color = getSubjectColor(event, subjectColors)); 155 | } 156 | 157 | return color; 158 | } 159 | 160 | function getTextColor( 161 | event: IFormatedShift | IEventDTO, 162 | theme: string, 163 | opacity: boolean, 164 | subjectColors: SubjectColor[] 165 | ) { 166 | let color: string = "#000000"; 167 | 168 | if (theme === "Modern") color = getDefaultColor(event); 169 | else if (theme === "Classic") color = "white"; 170 | else if (theme === "Custom") { 171 | opacity 172 | ? (color = getSubjectColor(event, subjectColors)) 173 | : (color = "white"); 174 | } 175 | 176 | return color; 177 | } 178 | 179 | export const useColorTheme = (filters: IFilterDTO[]) => { 180 | const [theme, setTheme] = useState(DEFAULT_THEME); 181 | const [opacity, setOpacity] = useState(true); 182 | const [subjectColors, setSubjectColors] = useState([]); 183 | 184 | const fetchThemeCallBack = useCallback(() => { 185 | fetchTheme(setTheme, setOpacity, setSubjectColors, filters); 186 | }, [filters]); 187 | 188 | useEffect(() => { 189 | fetchThemeCallBack(); 190 | }, [fetchThemeCallBack]); 191 | 192 | const saveThemeChanges = useCallback(() => { 193 | localStorage.setItem("theme", theme); 194 | localStorage.setItem("opacity", opacity.toString()); 195 | localStorage.setItem( 196 | "subjectColors", 197 | JSON.stringify( 198 | // store only the customized subject colors 199 | getDifferentSubjectColors( 200 | subjectColors, 201 | initializeSubjectColors(filters) 202 | ) 203 | ) 204 | ); 205 | }, [theme, opacity, subjectColors, filters]); 206 | 207 | return { 208 | saveThemeChanges, 209 | fetchTheme: fetchThemeCallBack, 210 | getBgColor: (event: IFormatedShift | IEventDTO) => 211 | getBgColor(event, theme, opacity, subjectColors), 212 | getTextColor: (event: IFormatedShift | IEventDTO) => 213 | getTextColor(event, theme, opacity, subjectColors), 214 | theme, 215 | setTheme, 216 | opacity, 217 | setOpacity, 218 | subjectColors, 219 | setSubjectColors, 220 | }; 221 | }; 222 | 223 | export default useColorTheme; 224 | -------------------------------------------------------------------------------- /hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export function useWindowSize() { 4 | // initialize state with undefined width/height so server and client renders match 5 | // learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ 6 | const [windowSize, setWindowSize] = useState({ 7 | width: undefined, 8 | height: undefined, 9 | }); 10 | 11 | useEffect(() => { 12 | // only execute all the code below in client side 13 | // handler to call on window resize 14 | function handleResize() { 15 | // set window width/height to state 16 | setWindowSize({ 17 | width: window.innerWidth, 18 | height: window.innerHeight, 19 | }); 20 | } 21 | 22 | // add event listener 23 | window.addEventListener("resize", handleResize); 24 | 25 | // call handler right away so state gets updated with initial window size 26 | handleResize(); 27 | 28 | // remove event listener on cleanup 29 | return () => window.removeEventListener("resize", handleResize); 30 | }, []); // empty array ensures that effect is only run on mount 31 | return windowSize; 32 | } 33 | 34 | export default useWindowSize; 35 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = ".next" 3 | command = "bun run build" 4 | 5 | [build.environment] 6 | TZ = "Europe/Lisbon" 7 | NODE_VERSION = "22.11.0" 8 | NEXT_VERSION = "14.2.3" 9 | 10 | [[plugins]] 11 | package = "@netlify/plugin-nextjs" 12 | 13 | [[headers]] 14 | for = "/*" 15 | [headers.values] 16 | X-Frame-Options = "DENY" 17 | X-XSS-Protection = "1; mode=block" 18 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withPWA = require("next-pwa")({ 2 | dest: "public", 3 | register: true, 4 | skipWaiting: true, 5 | disable: process.env.NODE_ENV === "development", 6 | }); 7 | 8 | /** @type {import('next').NextConfig} */ 9 | const path = require("path"); 10 | 11 | const nextConfig = { 12 | reactStrictMode: true, 13 | sassOptions: { 14 | includePaths: [path.join(__dirname, "styles")], 15 | }, 16 | transpilePackages: [ 17 | "rc-util", 18 | "rc-pagination", 19 | "rc-picker", 20 | "antd", 21 | "@ant-design", 22 | ], 23 | env: { 24 | PRIVATE_ID_EMAIL_SERVICE: "service_7hw69gg", 25 | 26 | PRIVATE_ID_TEMPLATE: "template_eojmboi", 27 | 28 | PRIVATE_ID_USER: "0BfQ5jno1Qi05monO", 29 | }, 30 | }; 31 | 32 | module.exports = nextConfig; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start", 7 | "lint": "npm-run-all lint:*", 8 | "lint:js": "next lint", 9 | "lint:css": "stylelint --fix './**/*.css' --ignore-path .gitignore", 10 | "format": "prettier --write . --ignore-path .gitignore", 11 | "test": "npm-run-all test:*", 12 | "test:lint": "npm-run-all test:lint:*", 13 | "test:lint:js": "next lint", 14 | "test:lint:css": "stylelint './**/*.css' --ignore-path .gitignore", 15 | "test:format": "prettier --check . --ignore-path .gitignore" 16 | }, 17 | "dependencies": { 18 | "@babel/runtime": "^7.23.9", 19 | "@emotion/react": "^11.10.5", 20 | "@emotion/styled": "^11.10.5", 21 | "@headlessui/react": "^1.7.17", 22 | "@mui/material": "^5.11.6", 23 | "@netlify/plugin-nextjs": "^5.2.2", 24 | "@tailwindcss/forms": "^0.5.5", 25 | "@types/react-big-calendar": "^0.38.1", 26 | "@types/react-dom": "^18.3.0", 27 | "antd": "^5.17.2", 28 | "emailjs": "^4.0.0", 29 | "emailjs-com": "^3.2.0", 30 | "googleapis": "^126.0.1", 31 | "html2canvas": "^1.4.1", 32 | "ical-generator": "^5.0.1", 33 | "markdown-to-jsx": "^7.4.0", 34 | "next": "^14.2.3", 35 | "next-pwa": "^5.6.0", 36 | "next-themes": "^0.2.1", 37 | "npm-run-all": "^4.1.5", 38 | "react": "^18.3.1", 39 | "react-big-calendar": "^1.10.2", 40 | "react-colorful": "^5.6.1", 41 | "react-dom": "^18.3.1", 42 | "sass": "^1.54.9", 43 | "stylelint": "^14.11.0", 44 | "stylelint-config-prettier": "^9.0.3", 45 | "stylelint-config-recess-order": "^3.0.0", 46 | "stylelint-config-standard": "^28.0.0", 47 | "stylelint-csstree-validator": "^2.0.0", 48 | "stylelint-prettier": "^2.0.0", 49 | "stylelint-scss": "^4.3.0" 50 | }, 51 | "devDependencies": { 52 | "@types/react": "^18.3.1", 53 | "autoprefixer": "^10.4.0", 54 | "eslint": "8.23.0", 55 | "eslint-config-next": "^14.2.3", 56 | "postcss": "^8.4.5", 57 | "prettier": "^2.7.1", 58 | "prettier-plugin-tailwindcss": "^0.1.1", 59 | "tailwindcss": "^3.3.3", 60 | "typescript": "^4.5.4" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import "../styles/globals.css"; 3 | import { ThemeProvider } from "next-themes"; 4 | 5 | function Calendarium({ Component, pageProps }: AppProps) { 6 | return ( 7 | 12 | 13 | 14 | ); 15 | } 16 | 17 | export default Calendarium; 18 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | import Script from "next/script"; 3 | 4 | export default function Document() { 5 | return ( 6 | 7 | 8 | {/* Fonts and Icons */} 9 | 13 | 14 | 15 | 19 | {/* Web App Related */} 20 | 21 | 22 | {/* Splash Screen configuration for IOS devices */} 23 | 24 | 29 | 34 | 39 | 44 | 49 | 54 | 59 | 64 | 69 | 74 |