1
0
mirror of https://github.com/XFox111/bonch-calendar.git synced 2026-04-22 07:08:01 +03:00

init: initial commit

This commit is contained in:
2025-11-18 20:16:48 +00:00
commit fe11e264de
69 changed files with 10008 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
{
"name": "bonch-calendar",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "lts",
"pnpmVersion": "none",
"nvmVersion": "latest"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"installDockerBuildx": true,
"version": "latest",
"dockerDashComposeVersion": "v2"
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [8000, 8080],
// "portsAttributes": {
// "5001": {
// "protocol": "https"
// }
// }
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": {
"api": "dotnet restore api/BonchCalendar.csproj",
"app": "cd app && npm install"
},
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"bierner.github-markdown-preview",
"dbaeumer.vscode-eslint",
"github.vscode-github-actions",
"GitHub.vscode-pull-request-github",
"Gruntfuggly.todo-tree",
"jock.svg",
"ms-azuretools.vscode-docker",
"ms-dotnettools.csdevkit",
"patcx.vscode-nuget-gallery",
"saeris.markdown-github-alerts",
"humao.rest-client"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
+1
View File
@@ -0,0 +1 @@
* @XFox111
+102
View File
@@ -0,0 +1,102 @@
name: "🐞 Bug Report"
description: Create a report to help us improve the project
title: "[Bug]: "
labels: ["bug", "needs-triage"]
assignees:
- xfox111
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: desc
attributes:
label: Description
description: A clear and concise description of what the bug is.
placeholder: e.g. The calendar is empty, even though timetable for my group is published.
validations:
required: true
- type: textarea
attributes:
label: Reproduction steps
description: Precisely describe minimal number of steps that make the bug to appear
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See '...'
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
placeholder: e.g. My timetable should be displayed in the calendar.
validations:
required: true
- type: textarea
attributes:
label: Screenshot
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: input
id: browser
attributes:
label: Browser name
placeholder: e.g. Google Chrome
validations:
required: true
- type: input
id: calendar
attributes:
label: Calendar service
placeholder: e.g. Google Calendar, Outlook, etc.
validations:
required: true
- type: input
id: group
attributes:
label: Faculty, course number and group number
description: |
If you don't want to share your group publicly, say "PM", and send it to feedback@xfox111.net. Reference the issue number.
placeholder: e.g. РТС, 2, РТ-31м
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false
- type: dropdown
id: requested-help
attributes:
label: Are you willing to submit a PR for this issue?
options:
- "yes"
- "no"
validations:
required: true
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
required: true
- label: The provided reproduction is a minimal reproducible example of the bug.
required: true
+7
View File
@@ -0,0 +1,7 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json
blank_issues_enabled: true
contact_links:
- name: Questions & Discussions
url: https://github.com/XFox111/bonch-calendar/discussions
about: Use GitHub discussions to ask your questions.
@@ -0,0 +1,62 @@
name: "🚀 New feature proposal"
description: Suggest a feature idea for this project
title: "[Feature]: "
labels: ["feature", "needs-triage"]
assignees:
- xfox111
body:
- type: markdown
attributes:
value: |
Thanks for your interest in the project and taking the time to fill out this feature report!
- type: textarea
id: proposition
attributes:
label: Proposed solution
description: Describe the solution you'd like
validations:
required: true
- type: textarea
id: justification
attributes:
label: Justification
description: Is your feature request related to a problem? Please describe.
validations:
required: true
- type: textarea
id: alts
attributes:
label: Alternatives
description: Describe alternatives you've considered.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: dropdown
id: requested-help
attributes:
label: Are you willing to submit a PR for this issue?
options:
- "yes"
- "no (I am aware that the project's author will not work on this issue)"
validations:
required: true
- type: checkboxes
id: checkboxes
attributes:
label: Validations
description: Before submitting the issue, please make sure you do the following
options:
- label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
required: true
+81
View File
@@ -0,0 +1,81 @@
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json
version: 2
updates:
- package-ecosystem: "npm"
directory: "/app"
target-branch: "main"
assignees:
- "XFox111"
schedule:
interval: monthly
rebase-strategy: disabled
groups:
deps:
patterns:
- "*"
exclude-patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
update-types:
- minor
- patch
react-next:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
update-types:
- major
- package-ecosystem: "nuget" # See documentation for possible values
directory: "/" # Location of package manifests
target-branch: "main"
assignees:
- "XFox111"
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
- package-ecosystem: "github-actions"
directory: "/"
target-branch: "main"
assignees:
- "XFox111"
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
- package-ecosystem: "devcontainers"
directory: "/"
target-branch: "main"
assignees:
- "XFox111"
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
- package-ecosystem: "docker"
directory: "/"
target-branch: "main"
assignees:
- "XFox111"
schedule:
interval: monthly
rebase-strategy: disabled
open-pull-requests-limit: 20
+6
View File
@@ -0,0 +1,6 @@
## Description
<!-- Put a detailed description of the pull request here (what does it change and how does it change?) -->
<!-- If you have an issue number, add it here with "resolves" prefix -->
<!-- For example: -->
<!-- resolves: #69420 -->
+88
View File
@@ -0,0 +1,88 @@
name: "Audit pipeline"
on:
push:
branches: [ "main" ]
paths-ignore:
- '.devcontainer/*'
- '.github/*'
- '!.github/workflows/audit.yml'
- '.vscode/*'
- '**.md'
- 'LICENSE'
- 'assets/*'
pull_request:
branches: [ "main" ]
paths-ignore:
- '.devcontainer/*'
- '.github/*'
- '!.github/workflows/audit.yml'
- '.vscode/*'
- '**.md'
- 'LICENSE'
- 'assets/*'
workflow_dispatch:
permissions:
packages: write
jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/build-push-action@v6
with:
context: ./api
tags: ${{ github.repository }}-api:ci
- run: docker save ${{ github.repository }}:ci | gzip > api_image.tar.gz
- uses: actions/upload-artifact@v5
with:
name: api-image
path: api_image.tar.gz
app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/build-push-action@v6
with:
context: ./app
tags: ${{ github.repository }}-app:ci
- run: docker save ${{ github.repository }}:ci | gzip > app_image.tar.gz
- uses: actions/upload-artifact@v5
with:
name: app-image
path: app_image.tar.gz
app_audit:
runs-on: ubuntu-latest
container: node:latest
steps:
- uses: actions/checkout@v5
- run: npm install
working-directory: ./app
- run: npm run lint
working-directory: ./app
- run: npm audit --audit-level=moderate
working-directory: ./app
- run: npm audit --audit-level=moderate --json > audit_report.json
working-directory: ./app
- uses: actions/upload-artifact@v5
with:
name: app-audit-report
path: ./app/audit_report.json
+110
View File
@@ -0,0 +1,110 @@
name: "Release pipeline"
on:
release:
types: [published]
workflow_dispatch:
permissions:
packages: write
jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/metadata-action@v5
id: meta
with:
images: |
${{ github.repository }}-api
ghcr.io/${{ github.repository }}-api
tags: |
latest
${{ github.ref_name }}
- name: "Login to Docker Hub"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./api
push: true
tags: ${{ steps.meta.outputs.tags }}
app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: docker/metadata-action@v5
id: meta
with:
images: |
${{ github.repository }}-app
ghcr.io/${{ github.repository }}-app
tags: |
latest
${{ github.ref_name }}
- name: "Login to Docker Hub"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./app
push: true
tags: ${{ steps.meta.outputs.tags }}
pages:
runs-on: ubuntu-latest
container: node:latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v5
- run: npm install
working-directory: ./app
- run: npm run build
env:
VITE_BACKEND_HOST: https://api.bonch.xfox111.net
working-directory: ./app
- name: Setup Pages
uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v4
with:
path: "./app/dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+15
View File
@@ -0,0 +1,15 @@
{
"recommendations": [
"bierner.github-markdown-preview",
"github.vscode-github-actions",
"GitHub.vscode-pull-request-github",
"Gruntfuggly.todo-tree",
"ms-dotnettools.csdevkit",
"patcx.vscode-nuget-gallery",
"saeris.markdown-github-alerts",
"dbaeumer.vscode-eslint",
"jock.svg",
"ms-azuretools.vscode-docker",
"humao.rest-client"
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
},
{
"name": "API: Launch BonchCalendar",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "api: build",
"program": "${workspaceFolder}/api/bin/Debug/net10.0/BonchCalendar.dll",
"cwd": "${workspaceFolder}/api",
"stopAtEntry": false,
"console": "internalConsole",
"suppressJITOptimizations": true,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:8080"
}
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"editor.rulers": [
{
"column": 120
}
],
"editor.insertSpaces": false,
"files.insertFinalNewline": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "always"
},
"files.eol": "\n",
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true,
"todo-tree.filtering.excludeGlobs": [
"**/node_modules/*/**",
"README.md"
]
}
+148
View File
@@ -0,0 +1,148 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "api: build",
"command": "dotnet",
"type": "process",
"group": "build",
"options": {
"cwd": "${workspaceFolder}/api"
},
"args": [
"build",
"BonchCalendar.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "api: publish",
"command": "dotnet",
"type": "process",
"group": "build",
"options": {
"cwd": "${workspaceFolder}/api"
},
"args": [
"publish",
"BonchCalendar.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "api: watch",
"command": "dotnet",
"type": "process",
"group": "test",
"options": {
"cwd": "${workspaceFolder}/api"
},
"args": [
"watch",
"run",
"--project",
"BonchCalendar.sln"
],
"problemMatcher": "$msCompile"
},
{
"label": "app: install",
"type": "npm",
"script": "install",
"group": "build",
"options": {
"cwd": "${workspaceFolder}/app"
},
"problemMatcher": []
},
{
"label": "app: build",
"type": "npm",
"script": "build",
"group": "build",
"options": {
"cwd": "${workspaceFolder}/app"
},
"problemMatcher": []
},
{
"label": "app: dev",
"type": "npm",
"script": "dev",
"group": "test",
"options": {
"cwd": "${workspaceFolder}/app"
},
"problemMatcher": []
},
{
"label": "app: lint",
"type": "npm",
"script": "lint",
"options": {
"cwd": "${workspaceFolder}/app"
},
"problemMatcher": []
},
{
"label": "app: preview",
"type": "npm",
"script": "preview",
"group": "test",
"options": {
"cwd": "${workspaceFolder}/app"
},
"problemMatcher": []
},
{
"label": "docker: build (app)",
"type": "process",
"command": "docker",
"group": "build",
"options": {
"cwd": "${workspaceFolder}/app"
},
"args": [
"build",
"-t xfox111/bonch-calendar-app:latest",
"."
],
"problemMatcher": []
},
{
"label": "docker: build (api)",
"type": "process",
"command": "docker",
"group": "build",
"options": {
"cwd": "${workspaceFolder}/api"
},
"args": [
"build",
"-t xfox111/bonch-calendar-api:latest",
"."
],
"problemMatcher": []
},
{
"label": "docker: compose up",
"type": "process",
"command": "docker",
"group": "test",
"options": {
"cwd": "${workspaceFolder}"
},
"args": [
"compose",
"up",
"--build",
"--force-recreate"
],
"problemMatcher": []
},
]
}
+135
View File
@@ -0,0 +1,135 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[opensource@xfox111.net](mailto:opensource@xfox111.net).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
> Contributor Covenant is released under the [Creative Commons Attribution 4.0 International Public License](https://github.com/EthicalSource/contributor_covenant/blob/release/LICENSE.md).
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+26
View File
@@ -0,0 +1,26 @@
# Contributing Guidelines
This document will guide you through the process of contributing to this project. We welcome contributions from the community and appreciate your efforts to improve it.
> [!NOTE]
> This document is a work in progress. More information will be added later.
## Asking questions
## Submitting bug reports and feature requests
## Code contributions
### What should I know/learn before I start?
### Setting up development environment
#### With devcontainers
#### Without devcontainers
### Building the project
### Getting familiar with the codebase
### Submitting a pull request
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Eugene Fox
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+48
View File
@@ -0,0 +1,48 @@
[![Website status](https://img.shields.io/website?url=http%3A//bonch.xfox111.net/)](https://bonch.xfox111.net)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/xfox111/bonch-calendar)](https://github.com/xfox111/bonch-calendar/releases/latest)
[![GitHub last commit](https://img.shields.io/github/last-commit/xfox111/bonch-calendar?label=Last+update)](https://github.com/XFox111/bonch-calendar/commits/main)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/light.png">
<img alt="Bonch.Calendar. Check your SPbSUT timetable in your calendar!">
</picture>
A simple service that provides web calendars that you can subscribe to in your calendar application (Google Calendar, Apple Calendar, Outlook, etc.) and see your group's timetable there.
This is my farewell gift to the university.
## Demo
<!-- TODO: put demo here -->
## Q&A
### Q: I have questions. Where do I ask?
You can go to the [discussions tab](https://github.com/bonch-calendar/discussions) to ask questions or start a discussion.
But before that, check out the [FAQ section](https://bonch.xfox111.net/#faq) on the website, as your question may already be answered there.
### Q: I want to contribute to the project. How can I do that?
First, you can check out [open issues](https://github.com/bonch-calendar/issues) or [discussions](https://github.com/bonch-calendar/discussions) to see if there are any tasks you can help with.
If you already found one, or have an idea for a new feature or improvement, you can create a pull request with your changes.
The [contributing guidelines](CONTRIBUTING.md) contain all the information you need.
> [!NOTE]
> Before starting to work on a new feature or bugfix, it's a good idea to open an issue first to discuss it with the maintainers. This way, you can ensure that your efforts align with the project's needs and avoid duplicating work.
### Q: I'd like to become a maintainer. How can I do that?
If you're interested in becoming a maintainer, please reach out to me via email at [eugene@xfox111.net](mailto:eugene@xfox111.net). You'll have to show that you're capable though.
---
[![Bluesky](https://img.shields.io/badge/%40xfox111.net-BSky?logo=bluesky&logoColor=%230285FF&label=Bluesky&labelColor=white&color=%230285FF)](https://bsky.app/profile/xfox111.net)
[![GitHub](https://img.shields.io/badge/%40xfox111-GitHub?logo=github&logoColor=%23181717&label=GitHub&labelColor=white&color=%23181717)](https://github.com/xfox111)
[![Buy Me a Coffee](https://img.shields.io/badge/%40xfox111-BMC?logo=buymeacoffee&logoColor=black&label=Buy%20me%20a%20coffee&labelColor=white&color=%23FFDD00)](https://buymeacoffee.com/xfox111)
> ©2025 Eugene Fox. Licensed under [MIT license](https://github.com/XFox111/bonch-calendar/blob/main/LICENSE)
+8
View File
@@ -0,0 +1,8 @@
# Security Policy
We as maintainers of this project are committed to maintaining the security of our software. We take security vulnerabilities seriously and will work to address them as quickly as possible.
We regularly run security audits and fix any security issues that are found. If you find a security issue, please report it to us as described below.
## Reporting a Vulnerability
You can report a security issue by going through [this link](https://github.com/XFox111/bonch-calendar/security/advisories/new)
+482
View File
@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
+482
View File
@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.1" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageReference Include="Ical.Net" Version="5.1.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
</ItemGroup>
</Project>
+23
View File
@@ -0,0 +1,23 @@
@Host = http://localhost:8080
GET {{Host}}/health
Accept: application/json
###
GET {{Host}}/faculties
Accept: application/json
###
GET {{Host}}/groups
?facultyId=56682
&course=2
Accept: application/json
###
@groupId = 56606
@facultyId = 50029
GET {{Host}}/timetable/{{facultyId}}/{{groupId}}
Accept: text/calendar
+34
View File
@@ -0,0 +1,34 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BonchCalendar", "BonchCalendar.csproj", "{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x64.ActiveCfg = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x64.Build.0 = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x86.ActiveCfg = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Debug|x86.Build.0 = Debug|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|Any CPU.Build.0 = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x64.ActiveCfg = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x64.Build.0 = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x86.ActiveCfg = Release|Any CPU
{811C13A0-E5FC-452C-8628-AD36B9A8A7E2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
+15
View File
@@ -0,0 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /build
ADD *.csproj .
RUN dotnet restore
ADD . ./
RUN dotnet publish --no-restore --configuration Release --output /out
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS prod
WORKDIR /app
COPY --from=build /out/* .
ENTRYPOINT [ "dotnet", "BonchCalendar.dll" ]
+26
View File
@@ -0,0 +1,26 @@
using BonchCalendar.Services;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace BonchCalendar.Health;
public class ApiHealthCheck(ApiService groupService) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default
)
{
try
{
Dictionary<int, string> faculties = await groupService.GetFacultiesListAsync();
if (faculties.Count > 0)
return HealthCheckResult.Healthy();
return HealthCheckResult.Degraded(description: "Timetable website looks to be up, but returned an empty list of faculties.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy(description: "Timetable website appears to be down.", exception: ex);
}
}
}
+130
View File
@@ -0,0 +1,130 @@
using System.ComponentModel.DataAnnotations;
using BonchCalendar;
using BonchCalendar.Health;
using BonchCalendar.Services;
using BonchCalendar.Utils;
using HealthChecks.UI.Client;
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.Serialization;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Mvc;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails(configure =>
{
configure.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["naas_reason"] = new NaasReasons().GetReason();
};
});
builder.Services
.AddScoped<ApiService>()
.AddScoped<ParsingService>();
builder.Services.AddHealthChecks()
.AddCheck<ApiHealthCheck>("timetable_website");
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy =>
policy
.WithMethods(["GET"])
.AllowAnyOrigin()
.AllowAnyHeader()
)
);
WebApplication app = builder.Build();
// Configure the HTTP request pipeline.
app.UseCors();
app.UseStatusCodePages();
app.MapOpenApi();
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
app.MapGet("/faculties", async ([FromServices] ApiService apiService) =>
{
logger.LogInformation("Fetching faculties list.");
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
return Results.Ok(faculties);
})
.WithName("GetFaculties")
.WithDescription("Gets the list of faculties.")
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK);
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int course) =>
{
logger.LogInformation("Fetching groups list for faculty {FacultyId} and course {Course}.", facultyId, course);
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, course);
return Results.Ok(groups);
})
.WithName("GetGroups")
.WithDescription("Gets the list of groups for the specified faculty and course.")
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK)
.ProducesValidationProblem();
app.MapGet("/timetable/{facultyId}/{groupId}", async (
int facultyId, int groupId,
[FromServices] ApiService apiService,
[FromServices] ParsingService parsingService
) =>
{
logger.LogInformation("Generating timetable for group {GroupId} of faculty {FacultyId}.", groupId, facultyId);
string cacheFile = Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
if (File.Exists(cacheFile) && (DateTime.UtcNow - File.GetLastWriteTimeUtc(cacheFile)).TotalHours < 6)
{
if (args.Contains("--no-cache"))
logger.LogWarning("Cache disabled via --no-cache, regenerating timetable for group {GroupId}.", groupId);
else
{
logger.LogInformation("Serving timetable for group {GroupId} from cache.", groupId);
return Results.Text(await File.ReadAllTextAsync(cacheFile), contentType: "text/calendar");
}
}
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
foreach (TimetableType type in types)
{
classesRaw = await apiService.GetScheduleDocumentAsync(groupId, type);
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
}
Calendar calendar = new();
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H"));
calendar.Events.AddRange(timetable);
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
string serialized = new CalendarSerializer().SerializeToString(calendar)!;
await File.WriteAllTextAsync(cacheFile, serialized);
logger.LogInformation("Cached timetable for group {GroupId} to {CacheFile}.", groupId, cacheFile);
return Results.Text(serialized, contentType: "text/calendar");
})
.WithName("GetTimetable")
.WithDescription("Gets the iCal timetable for the specified group.")
.Produces<string>(StatusCodes.Status200OK, "text/calendar")
.ProducesValidationProblem();
app.Run();
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:8443;http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using BonchCalendar.Utils;
namespace BonchCalendar.Services;
public class ApiService
{
public async Task<Dictionary<int, string>> GetFacultiesListAsync() =>
ParseListResponse(await SendRequestAsync(new()
{
["choice"] = "1", // "choice" is always "1" (idk why, don't ask me)
["schet"] = GetCurrentSemesterId()
}));
public async Task<Dictionary<int, string>> GetGroupsListAsync(int facultyId, int course) =>
ParseListResponse(await SendRequestAsync(new()
{
["choice"] = "1",
["schet"] = GetCurrentSemesterId(),
["faculty"] = facultyId.ToString(), // Specifying faculty ID returns a list of groups
["kurs"] = course.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty.
}));
public async Task<string> GetScheduleDocumentAsync(int groupId, TimetableType timetableType) =>
await SendRequestAsync(new()
{
["schet"] = GetCurrentSemesterId(),
["type_z"] = ((int)timetableType).ToString(),
["group"] = groupId.ToString()
});
public async Task<DateTime> GetSemesterStartDateAsync(int groupId)
{
using HttpClient client = new();
string content = await client.GetStringAsync($"https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={groupId}");
using IHtmlDocument doc = new HtmlParser().ParseDocument(content);
string labelText = doc.QuerySelector("a#rasp-prev + div:nth-child(2) > span")!.TextContent;
int weekNumber = int.Parse(ParserUtils.NumberRegex().Match(labelText).Value);
DateTime currentDate = DateTime.Today;
currentDate = currentDate
.AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday
.AddDays(-7 * (weekNumber - 1)); // Move back to the first week
return currentDate;
}
private static Dictionary<int, string> ParseListResponse(string responseContent) =>
responseContent
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(item => item.Split(','))
.ToDictionary(
parts => int.Parse(parts[0]),
parts => parts[1]
);
public async Task<string> SendRequestAsync(Dictionary<string, string> formData)
{
HttpRequestMessage request = new(HttpMethod.Post, "https://cabinet.sut.ru/raspisanie_all_new.php")
{
Content = new FormUrlEncodedContent(formData)
};
using HttpClient client = new(new HttpClientHandler
{
// Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
});
HttpResponseMessage response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
private static string GetCurrentSemesterId()
{
DateTime now = DateTime.Today;
int currentSemester = now.Month is >= 8 or < 2
? 1 // August through January - first semester
: 2; // Everything else - second
int termStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025)
if (now.Month < 8) // Before August means we are in the second semester of the previous academic year
termStartYear--;
return $"205.{termStartYear}{termStartYear + 1}/{currentSemester}";
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using BonchCalendar.Utils;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
namespace BonchCalendar.Services;
public partial class ParsingService
{
public CalendarEvent[] ParseGeneralTimetable(string rawHtml, DateTime semesterStartDate, string groupName)
{
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
IHtmlCollection<IElement> rawClasses = doc.QuerySelectorAll(".pair");
List<CalendarEvent> classes = [];
foreach (IElement classItem in rawClasses)
{
var (className, classType, professors, auditorium) = ParseBaseInfo(classItem);
int weekday = int.Parse(classItem.GetAttribute("weekday")!);
string timeLabelText = classItem.ParentElement!.ParentElement!.Children[0].TextContent;
Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText);
string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText;
(TimeSpan startTime, TimeSpan endTime) = !timeMatch.Success ?
ParserUtils.GetTimesFromLabel(timeLabelText) :
(
TimeSpan.Parse(timeMatch.Groups["start"].Value),
TimeSpan.Parse(timeMatch.Groups["end"].Value)
);
int[] weeks = [
.. ParserUtils.NumberRegex().Matches(classItem.QuerySelector(".weeks")!.TextContent)
.Select(i => int.Parse(i.Value))
];
foreach (int week in weeks)
{
DateTime classDate = semesterStartDate
.AddDays((week - 1) * 7) // Move to the correct week
.AddDays(weekday - 1); // Move to the correct weekday
classes.Add(GetEvent(
$"{number}. {className} ({classType})", auditorium,
GetDescription(groupName, professors, auditorium, weeks),
classDate, startTime, endTime));
}
}
return [.. classes];
}
public CalendarEvent[] ParseExamTimetable(string rawHtml, string groupName)
{
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
IHtmlCollection<IElement> rawClasses = doc.QuerySelectorAll(".pair");
List<CalendarEvent> classes = new(rawClasses.Count);
foreach (IElement classItem in rawClasses)
{
var (className, classType, professors, auditorium) = ParseBaseInfo(classItem);
DateTime classDate = DateTime.Parse(classItem.Children[0].ChildNodes[0].TextContent, CultureInfo.GetCultureInfo("ru-RU"));
Match timeMatch = ParserUtils.ExamTimeRegex().Match(classItem.GetAttribute("pair")!);
if (!timeMatch.Success)
timeMatch = ParserUtils.ExamTimeAltRegex().Match(classItem.GetAttribute("pair")!);
string number = timeMatch.Groups["number"].Success ?
$"{timeMatch.Groups["number"].Value}. " : string.Empty;
TimeSpan startTime = TimeSpan.Parse(timeMatch.Groups["start"].Value.Replace('.', ':'));
TimeSpan endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':'));
classes.Add(GetEvent(
$"{number}{className} ({classType})", auditorium,
GetDescription(groupName, professors, auditorium),
classDate, startTime, endTime));
}
return [.. classes];
}
private static CalendarEvent GetEvent(string title, string auditorium, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) =>
new()
{
Summary = title,
Description = description,
Start = new CalDateTime(date.Add(startTime - TimeSpan.FromHours(3)).ToUniversalTime()),
End = new CalDateTime(date.Add(endTime - TimeSpan.FromHours(3)).ToUniversalTime()),
Location = auditorium
};
private static string GetDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null)
{
string str = $"""
Группа: {groupName}
Преподаватель(и):
- {string.Join("\n- ", professors)}
""";
if (weeks is not null && weeks.Length > 0)
str += $"\nНедели: {string.Join(", ", weeks)}";
Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium);
if (!auditoriumMatch.Success)
auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium);
if (auditoriumMatch.Success)
str += "\n\n" + $"""
ГУТ.Навигатор:
https://nav.sut.ru/?cab=k{auditoriumMatch.Groups["wing"].Value}-{auditoriumMatch.Groups["room"].Value}
""";
return str;
}
private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement)
{
string className = classElement.QuerySelector(".subect")!.TextContent;
string classType = classElement.QuerySelector(".type")!.TextContent
.Replace("(", string.Empty).Replace(")", string.Empty).Trim();
string[] professors = classElement.QuerySelector(".teacher[title]")!.GetAttribute("title")
!.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
string auditorium = classElement.QuerySelector(".aud")!.TextContent
.Replace("ауд.:", string.Empty).Replace(';', ',').Trim();
return (className, classType, professors, auditorium);
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace BonchCalendar;
public enum TimetableType
{
Classes = 1,
Exams = 2,
ExamsForExtramural = 4,
Attestations = 14
}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
using System.Text.RegularExpressions;
namespace BonchCalendar.Utils;
public static partial class ParserUtils
{
public static (TimeSpan startTime, TimeSpan endTime) GetTimesFromLabel(string label)
{
(string startTime, string endTime) = label switch
{
"1" => ("9:00", "10:35"),
"2" => ("10:45", "12:20"),
"3" => ("13:00", "14:35"),
"4" => ("14:45", "16:20"),
"5" => ("16:30", "18:05"),
"6" => ("18:15", "19:50"),
"7" => ("20:00", "21:35"),
"Ф1" => ("9:00", "10:30"),
"Ф2" => ("10:30", "12:00"),
"Ф3" => ("12:00", "13:30"),
"Ф4" => ("13:30", "15:00"),
"Ф5" => ("15:00", "16:30"),
"Ф6" => ("16:30", "18:00"),
"Ф7" => ("18:00", "19:30"),
_ => throw new NotImplementedException(),
};
return (TimeSpan.Parse(startTime), TimeSpan.Parse(endTime));
}
[GeneratedRegex(@"^(?<number>\S+)\s\((?<start>\d+:\d+)-(?<end>\d+:\d+)\)$")]
public static partial Regex TimeLabelRegex();
[GeneratedRegex(@"\d+")]
public static partial Regex NumberRegex();
[GeneratedRegex(@"^(?<room>\d+),\sБ22\/(?<wing>\d)$")]
public static partial Regex AuditoriumRegex();
[GeneratedRegex(@"^(?<room>\d+),\sпр\.Большевиков,22,к\.(?<wing>\d)$")]
public static partial Regex AuditoriumAltRegex();
[GeneratedRegex(@"^(?<number>\d)\s\((?<start>\d+\.\d+)-(?<end>\d+\.\d+)\)$")]
public static partial Regex ExamTimeRegex();
[GeneratedRegex(@"^(?<start>\d+:\d+)-(?<end>\d+:\d+)$")]
public static partial Regex ExamTimeAltRegex();
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1
View File
@@ -0,0 +1 @@
VITE_BACKEND_HOST=https://api.bonch.xfox111.net
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+30
View File
@@ -0,0 +1,30 @@
# Use the official Node.js image as the base image
FROM node:latest AS builder
ARG API_HOST=https://api.bonch.xfox111.net
# Set the working directory inside the container
WORKDIR /app
# Copy the package.json and package-lock.json files to the working directory
COPY package*.json ./
# Install the app dependencies
RUN npm install
# Copy the app source code to the working directory
COPY . .
RUN echo "VITE_BACKEND_HOST=${API_HOST}" > .env.production
# Build the app
RUN npm run build
# Use the official Nginx image as the base image
FROM nginx:latest AS prod
# Copy the build output to replace the default Nginx contents
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80
CMD ["nginx", "-g", "daemon off;"]
+27
View File
@@ -0,0 +1,27 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules:
{
"@typescript-eslint/no-unused-vars": "warn"
}
},
]);
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/bonch-calendar.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;600&display=swap" rel="stylesheet">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#4D4D4D" />
<meta name="color-scheme" content="light dark" />
<title>Бонч.Календарь</title>
<meta name="description" content="Смотри расписание СПбГУТ в своем календаре" />
<link rel="author" href="https://xfox111.net" />
<meta name="author" content="Eugene Fox" />
<meta name="keywords"
content="Bonch,SPbSUT,Бонч,СПбГУТ,расписание,календарь,пары,schedule,timetable,classes,calendar,Eugene Fox,Michael Gordeev,Mikhail Gordeev" />
<link rel="canonical" href="https://bonch.xfox111.net" />
<meta property="og:title" content="Бонч.Календарь" />
<meta property="og:description" content="Смотри расписание СПбГУТ в своем календаре" />
<meta property="og:site_name" content="bonch.xfox111.net" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="600" />
<meta property="og:image" content="https://bonch.xfox111.net/opengraph.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@xfox111" />
<meta name="twitter:title" content="Бонч.Календарь" />
<meta name="twitter:description" content="Смотри расписание СПбГУТ в своем календаре" />
<meta name="twitter:image:type" content="image/png" />
<meta name="twitter:image:width" content="1200" />
<meta name="twitter:image:height" content="600" />
<meta name="twitter:image" content="https://bonch.xfox111.net/opengraph.png" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16" />
<link rel="icon" href="/bonch-calendar.svg" type="image/svg+xml" sizes="any" />
<link rel="apple-touch-icon" href="/apple-icon.png" type="image/png" sizes="180x180" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5255
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "bonch-calendar",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fluentui/react-components": "^9.72.7",
"@fluentui/react-icons": "^2.0.314",
"@fluentui/react-motion-components-preview": "^0.14.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-localization": "^2.0.6"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="100%" height="100%" viewBox="0 0 2048 2048" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Bonch" serif:id="Bonch">
<g transform="matrix(20.48,0,0,20.48,-7359.8976,-1024)">
<path
d="M424.82,150C436.27,137.11 444,116.54 444,100.77C444,90 440.87,70.55 424.82,50L422.77,51C432.28,67.33 433.71,81.44 433.71,100.77C433.71,104.66 433.92,133.23 422.98,148.77L424.82,150Z"
style="fill:rgb(246,139,31);fill-rule:nonzero;" />
<path
d="M398.82,142.19C408.48,131.31 415,114 415,100.65C415,91.56 412.32,75.15 398.78,57.81L397.06,58.66C405.06,72.44 406.29,84.34 406.29,100.66C406.29,103.94 406.46,128.04 397.23,141.17L398.82,142.19Z"
style="fill:rgb(246,139,31);fill-rule:nonzero;" />
<path
d="M376.15,134.37C384.02,125.52 389.36,111.37 389.36,100.53C389.36,93.12 387.18,79.75 376.15,65.63L374.74,66.31C381.28,77.54 382.26,87.24 382.26,100.53C382.26,103.2 382.4,122.84 374.88,133.53L376.15,134.37Z"
style="fill:rgb(246,139,31);fill-rule:nonzero;" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+94
View File
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
©2025 Eugene Fox. All rights reserved.
This graphic file is excempt from the MIT license that applies to the rest of the
project. You are not allowed to use it in your own projects without explicit permission
from the author.
See https://github.com/XFox111/my-website/blob/main/COPYING for more information.
-->
<svg id="Layer_5" data-name="Layer 5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2000 1000">
<defs>
<style>
.cls-1,
.cls-2,
.cls-3 {
stroke-width: 0px;
}
.cls-1,
.cls-4 {
fill: #ff7545;
}
.cls-2,
.cls-6 {
fill: #242424;
}
.cls-5 {
stroke-width: 12px;
stroke-linejoin: round;
}
.cls-6,
.cls-4 {
stroke: #242424;
stroke-linejoin: round;
}
.cls-6,
.cls-4 {
stroke-linecap: round;
stroke-width: 8px;
}
.cls-3 {
fill: #fff;
}
.laptop {
fill: #424242;
stroke: #424242;
}
@media (prefers-color-scheme: dark) {
.laptop {
fill: #d6d6d6;
stroke: #d6d6d6;
}
}
</style>
</defs>
<g>
<path class="cls-1"
d="M1656,996.17c-62.9,0-124.32-15.87-177.6-45.9-48.31-27.23-88.43-65.09-116.63-109.96,42.24,16.15,87.02,24.34,133.29,24.34,191.24,0,346.83-142.61,346.83-317.91,0-49.29-17.77-79.71-40.27-118.22-.25-.44-.51-.87-.77-1.31,56.26,25.15,103.09,59.13,135.92,98.71,38.75,46.72,58.4,100.56,58.4,160,0,82.81-35.24,160.68-99.22,219.27-64.08,58.67-149.29,90.99-239.96,90.99Z" />
<path class="cls-2"
d="M1810.27,435.77c21.37,10.21,41.26,21.71,59.35,34.32,24.99,17.43,46.59,37.03,64.2,58.27,38.17,46.02,57.52,99.03,57.52,157.56,0,41.28-8.83,81.34-26.25,119.04-16.85,36.47-40.98,69.24-71.73,97.4-30.8,28.2-66.67,50.34-106.62,65.82-41.4,16.04-85.39,24.17-130.75,24.17-62.25,0-123.01-15.7-175.72-45.4-44.28-24.95-81.59-58.94-108.99-99.08,39.45,13.69,80.98,20.62,123.77,20.62,193.35,0,350.66-144.33,350.66-321.74,0-46.31-15.25-76.1-35.44-110.97M1791.69,419.07c25.27,43.77,46.37,74.72,46.37,127.67,0,173.46-153.57,314.08-343,314.08-50.83,0-99.07-10.14-142.46-28.3,57.52,99.6,171.79,167.48,303.4,167.48,189.44,0,343-140.62,343-314.08,0-126.92-88.98-217.31-207.31-266.85h0Z" />
</g>
<path class="cls-4"
d="M1850.7,210.11c40.63,49.7,33.28,122.93-16.42,163.56-49.7,40.63-174.15,75.16-214.78,25.46-40.63-49.7,17.94-164.81,67.64-205.44,49.7-40.63,122.93-33.28,163.56,16.42Z" />
<g>
<path class="cls-1"
d="M1141.23,996c-107.8,0-211.68-30.24-292.51-85.15-34.04-23.13-63.19-50-86.62-79.87-30.95-39.44-50.89-82.52-59.27-128.07,2.74-4.13,13.24-18.52,34.99-33.02,23.72-15.81,66.05-35,133.04-36.62,3.24-.07,6.3-.11,9.33-.11,43.77,0,86.56,14.88,130.79,45.49,38.84,26.88,73.68,62.33,104.42,93.61,24.59,25.02,47.83,48.66,69.78,64.67,62.27,45.4,122.66,67.87,162.35,78.74,26.53,7.27,47.34,10.45,59.7,11.84-35.66,20.71-74.9,37.05-116.83,48.62-47.77,13.19-97.96,19.88-149.17,19.88Z" />
<path class="cls-2"
d="M880.19,637.16c42.93,0,84.97,14.65,128.51,44.78,38.53,26.66,73.23,61.97,103.85,93.12,24.71,25.14,48.06,48.89,70.28,65.1,62.76,45.75,123.63,68.41,163.65,79.36,19.52,5.35,36,8.51,48.36,10.37-32.55,17.79-67.94,32.01-105.51,42.38-47.43,13.09-97.26,19.73-148.11,19.73-54.46,0-107.6-7.59-157.95-22.55-48.57-14.43-93.08-35.26-132.31-61.91-33.7-22.89-62.54-49.48-85.72-79.03-30.18-38.46-49.75-80.41-58.19-124.72,8.21-11.64,51.24-63.8,163.88-66.52,3.21-.07,6.24-.11,9.25-.11M880.19,629.16c-3.2,0-6.34.04-9.43.11-132.17,3.19-172.15,72.82-172.15,72.82,8.48,47.56,29.48,92.03,60.34,131.36,23.74,30.26,53.31,57.47,87.52,80.71,78.67,53.44,181.82,85.84,294.76,85.84,105.42,0,202.3-28.24,278.72-75.46,0,0-28.21-.92-71.36-12.74-43.16-11.81-101.26-34.52-161.05-78.11-76.68-55.9-167.65-204.53-307.35-204.53h0Z" />
</g>
<g>
<path class="cls-3"
d="M760.28,828.65c-29.91-38.81-49.23-81.08-57.46-125.74,2.74-4.13,13.24-18.52,34.99-33.02,23.38-15.59,64.87-34.46,130.24-36.54l51.71,133.53-159.48,61.77Z" />
<path class="cls-2"
d="M865.35,637.45l49.24,127.14-152.95,59.24c-28.13-37.18-46.48-77.52-54.58-120.04,8.07-11.44,49.8-62.07,158.29-66.35M870.76,629.27c-132.17,3.19-172.15,72.82-172.15,72.82,8.48,47.56,29.48,92.03,60.34,131.36l165.99-64.29-54.18-139.89h0Z" />
</g>
<rect class="cls-5 laptop" x="1219.11" y="766.83" width="270.32" height="9.95"
transform="translate(304.74 -380.67) rotate(18)" />
<rect class="cls-5 laptop" x="1059.2" y="596.18" width="270.32" height="9.95"
transform="translate(1479.19 -705.28) rotate(75.58)" />
<path class="cls-6"
d="M1666.04,416.09c1.06-29.87-22.29-54.95-52.17-56.01-2.32-.08-4.6,0-6.85.2.75,15,4.9,28.4,13.47,38.89,10.4,12.71,26.28,19.9,44.99,22.9.29-1.96.48-3.95.55-5.98Z" />
<path class="cls-4"
d="M1851.96,176.25c-29.01-25.87-78.84-33.24-78.84-33.24,0,0,37.28-38.45,83.99-62.26,46.65-23.78,102.73-32.92,102.73-32.92,0,0-26.34,46.29-43.76,93.12-19.06,51.23-22.35,98.62-22.35,98.62,0,0-13.99-38.55-41.77-63.33Z" />
<ellipse class="cls-2" cx="1700.37" cy="301.32" rx="10.19" ry="17.93"
transform="translate(271.38 1270.89) rotate(-44.21)" />
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+34
View File
@@ -0,0 +1,34 @@
import { FluentProvider, makeStyles, type Theme } from "@fluentui/react-components";
import { type ReactElement } from "react";
import { useTheme } from "./hooks/useTheme";
import MainView from "./views/MainView";
import FaqView from "./views/FaqView";
import DedicatedView from "./views/DedicatedView";
import FooterView from "./views/FooterView";
export default function App(): ReactElement
{
const theme: Theme = useTheme();
const cls = useStyles();
return (
<FluentProvider theme={ theme }>
<main className={ cls.root }>
<MainView />
<FaqView />
<DedicatedView />
<FooterView />
</main>
</FluentProvider>
);
}
const useStyles = makeStyles({
root:
{
display: "flex",
flexFlow: "column",
alignItems: "center",
minHeight: "100vh"
}
})
+49
View File
@@ -0,0 +1,49 @@
import { webDarkTheme, webLightTheme, type Theme } from "@fluentui/react-components";
import { useEffect, useState } from "react";
const media: MediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
const getTheme = (isDark: boolean) => isDark ? darkTheme : lightTheme;
const baseTheme: Partial<Theme> =
{
fontFamilyBase: "\"Fira Sans\", sans-serif",
colorBrandForeground1: "#f68b1f",
colorBrandStroke1: "#f68b1f",
colorBrandForegroundLink: "#f68b1f",
colorBrandForegroundLinkHover: "#c36e18",
colorBrandForegroundLinkPressed: "#a95f15",
colorBrandStroke2Contrast: "#FDE6CE",
colorBrandBackground: "#f68b1f",
colorBrandBackgroundHover: "#c36e18",
colorNeutralForeground2BrandHover: "#c36e18",
colorBrandBackgroundPressed: "#a95f15",
colorCompoundBrandStroke: "#f68b1f",
colorCompoundBrandStrokePressed: "#a95f15"
};
const lightTheme: Theme =
{
...webLightTheme, ...baseTheme,
colorNeutralForeground1: "#000000",
colorNeutralForeground2: "#4D4D4D"
};
const darkTheme: Theme =
{
...webDarkTheme, ...baseTheme,
colorNeutralBackground2: "#4D4D4D"
};
export function useTheme(): Theme
{
const [theme, setTheme] = useState<Theme>(getTheme(media.matches));
useEffect(() =>
{
const updateTheme = (args: MediaQueryListEvent) => setTheme(getTheme(args.matches));
media.addEventListener("change", updateTheme);
return () => media.removeEventListener("change", updateTheme);
}, []);
return theme;
}
+17
View File
@@ -0,0 +1,17 @@
import { useCallback, useState } from "react";
export default function useTimeout(timeout: number): [boolean, () => void]
{
const [isActive, setActive] = useState<boolean>(false);
const trigger = useCallback(() =>
{
if (isActive)
return;
setActive(true);
setTimeout(() => setActive(false), timeout);
}, [timeout, isActive]);
return [isActive, trigger];
}
+29
View File
@@ -0,0 +1,29 @@
body
{
margin: 0;
padding: 0;
box-sizing: border-box;
overflow-x: hidden;
font-family: "Fira Sans", sans-serif;
user-select: none;
}
h1, h2, h3, ul, p
{
margin: 0;
}
@keyframes scaleUpFade
{
0%
{
opacity: 0;
transform: scale(0.8);
}
100%
{
opacity: 1;
transform: scale(1);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import strings from "./utils/strings.ts";
const preferredLanguages = navigator.languages.map(lang => lang.split("-")[0].toLocaleLowerCase());
if (
(preferredLanguages.includes("ru")&& !window.location.pathname.startsWith("/en")) ||
window.location.pathname.startsWith("/ru")
)
strings.setLanguage("ru");
else
{
strings.setLanguage("en");
document.title = strings.formatString(strings.title_p1, strings.title_p2) as string;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
+11
View File
@@ -0,0 +1,11 @@
export const fetchFaculties = async (): Promise<[string, string][]> =>
{
const res = await fetch(import.meta.env.VITE_BACKEND_HOST + "/faculties");
return Object.entries(await res.json());
};
export const fetchGroups = async (facultyId: string, course: number): Promise<[string, string][]> =>
{
const res = await fetch(`${import.meta.env.VITE_BACKEND_HOST}/groups?facultyId=${facultyId}&course=${course}`);
return Object.entries(await res.json());
};
+7
View File
@@ -0,0 +1,7 @@
import { Link } from "@fluentui/react-components";
import type { ReactElement } from "react";
const extLink = (url: string, text: string): ReactElement =>
<Link href={ url } target="_blank" rel="noreferrer">{ text }</Link>;
export default extLink;
+124
View File
@@ -0,0 +1,124 @@
import LocalizedStrings from "react-localization";
const strings = new LocalizedStrings({
en:
{
// MainView.tsx
title_p1: "Bonch.{0}",
title_p2: "Calendar",
subtitle_p1: "Check your SPbSUT classes in {0} calendar",
subtitle_p2: "your",
pickFaculty: "1. Pick your faculty",
pickCourse: "2. Pick your course",
pickGroup: "3. Pick your group",
pickGroup_empty: "No groups are available for the selected course",
subscribe: "4. Subscribe to the calendar",
copy: "Copy link",
or: "or",
download: "Download .ics file",
cta: "Like the service? Tell your classmates!",
// FaqView.tsx
faq_h2: "Frequently asked questions",
question1_h3: "How do I save timetable to my Outlook/Google calendar?",
answer1_p1: "Once you picked your group, copy the generated link. Then, in your calendar app subscirbe to a new calendar using that link. Here're some guides:",
answer1_li1: "Google Calendar",
answer1_li2: "Outlook",
answer1_li3: "Apple iCloud",
answer1_p2: "Note that subscribing to a web calendar is available only on desktop versions of Google and Outlook. But once you subscribe, the timetable will be available on all your devices.",
question2_h3: "The timetable can change. Do I need to re-import the calendar every time?",
answer2_p1: "Unless you imported the calendar from file, no. Subscribed calendars update automatically. On our end, calendars update every six hours.",
question3_h3: "My group/faculty doesn't appear in the list. How do I get my timetable?",
answer3_p1: "If your faculty or group is missing, it probably means that the timetable for it is not yet published. Try again later.",
answer3_p2: "If you already have a calendar link, it will work once the timetable is published.",
question4_h3: "Do I need to re-import my calendar at the start of each semester/year?",
answer4_p1: "No. The generated calendar link is valid indefinitely and will always point to the latest timetable for your group.",
answer4_p2: "That being said, if your group or faculty changes their name, you might need to generate a new link, as the group and faculty IDs might change.",
question5_h3: "Does the calendar show timetable from past semesters?",
answer5_p1: "No. The calendar contains only the current semester's timetable. Once you enter a new semester, all past events will disappear.",
answer5_p2: "If you want to keep past semesters' timetables, consider downloading them as files at the end of each semester.",
question6_h3: "Something doesn't work (timetable doesn't show, the website is broken, etc.). How do I report this?",
answer6_p1: "You can file an issue on project's {0} or contact me {1} (the former is preferred).",
answer6_p1_link1: "GitHub page",
answer6_p1_link2: "via email",
answer6_p2: "Note that I am no longer a student and work on this project in my spare time. So if you want to get a fix quickly, consider submitting a pull request yourself. You can find all the necessary information on project's {0}.",
answer6_p2_link: "GitHub page",
question7_h3: "I want a propose a new feature. Do I file it on GitHub as well?",
answer7_p1: "I do not accept any feature requests for this project. However, if you want to propose a new feature, then yes, you can still file an issue on project's {0} and maybe someone else will implement it.",
answer7_p1_link: "GitHub page",
answer7_p2: "The other way is to implement the feature yourself and submit a pull request. I do welcome contributions.",
question8_h3: "GUT.Schedule app doesn't work anymore. Will it be fixed?",
answer8_p1: "GUT.Schedule application is no longer supported. This project is a successor to that app, so please use it instead.",
answer8_p2: "That being said, the GUT.Schedule app is open source as well, so if you'd like to tinker with it, you can find its source code {0}.",
answer8_p2_link: "on GitHub",
// DedicatedView.tsx
dedicated_h2: "Dedicated to memory of Scientific and educational center \"Technologies of informational and educational systems\"",
dedicated_p: "Always in our hearts ❤️",
// FooterView.tsx
footer_p1: "Made with ☕ and ❤️{0}by {1}",
footer_p2: "Eugene Fox"
},
ru:
{
// MainView.tsx
title_p1: "Бонч.{0}",
title_p2: "Календарь",
subtitle_p1: "Смотри расписание СПбГУТ в {0} календаре",
subtitle_p2: "своем",
pickFaculty: "1. Выбери свой факультет",
pickCourse: "2. Выбери свой курс",
pickGroup: "3. Выбери свою группу",
pickGroup_empty: "Нет доступных групп для выбранного курса",
subscribe: "4. Подпишись на календарь",
copy: "Скопировать ссылку",
or: "или",
download: "Скачай .ics файл",
cta: "Понравился сервис? Расскажи одногруппникам!",
// FaqView.tsx
faq_h2: "Часто задаваемые вопросы",
question1_h3: "Как сохранить расписание в Outlook/Google календарь?",
answer1_p1: "После того, как вы выбрали свою группу, скопируйте сгенерированную ссылку. Затем в своем календаре подпишитесь на новый календарь, используя эту ссылку. Вот несколько инструкций:",
answer1_li1: "Google Календарь",
answer1_li2: "Outlook",
answer1_li3: "Apple Календарь",
answer1_p2: "Обратите внимание, что в Google и Outlook подписаться на веб-календарь можно только в веб-версиях этих сервисов. Но после этого расписание будет доступно на всех ваших устройствах.",
question2_h3: "Расписание может меняться. Нужно ли мне импортировать календарь каждый раз?",
answer2_p1: "Если вы не импортировали календарь из файла, то нет. Календари на которые вы подписаны обновляются автоматически. С нашей стороны, календари обновляются каждые шесть часов.",
question3_h3: "Моя группа/факультет не отображается в списке. Как мне получить свое расписание?",
answer3_p1: "Если ваш факультет или группа отсутствует, скорее всего, расписание для них еще не опубликовано. Попробуйте позже.",
answer3_p2: "Если у вас уже есть ссылка на календарь, можете использовать ее. Расписание появится в календаре сразу как только оно будет опубликовано.",
question4_h3: "Нужно ли мне повторно импортировать календарь в начале каждого семестра/года?",
answer4_p1: "Нет. Сгенерированная ссылка на календарь действительна бессрочно и всегда будет указывать на актуальное расписание для вашей группы.",
answer4_p2: "Однако, если ваша группа или факультет изменили свое название, возможно, вам придется сгенерировать новую ссылку, так как идентификаторы группы или факультета могли также измениться.",
question5_h3: "Показывает ли календарь расписание из прошлых семестров?",
answer5_p1: "Нет. Календарь содержит только расписание текущего семестра. Как только начнется новый семестр, все прошедшие события исчезнут.",
answer5_p2: "Если вы все же хотите сохранить расписания прошлых семестров, вы можете скачивать их в виде файлов в конце каждого семестра.",
question6_h3: "Что-то не работает (расписание не отображается, сайт сломан и т.д.). Как мне об этом сообщить?",
answer6_p1: "Вы можете создать задачу на {0} проекта или связаться со мной {1} (первый вариант предпочтительнее).",
answer6_p1_link1: "странице GitHub",
answer6_p1_link2: "по электронной почте",
answer6_p2: "Обратите внимание, что я больше не являюсь студентом и работаю над этим проектом в свое свободное время. Поэтому, если вы хотите быстро получить исправление, вы можете самостоятельно создать пул реквест. Вся необходимая информация доступна на {0} проекта.",
answer6_p2_link: "странице GitHub",
question7_h3: "Я хочу предложить новую функцию. Это также делается через GitHub?",
answer7_p1: "Я не принимаю запросы на добавление функций для этого проекта. Однако, если вы хотите предложить новую функцию, то да, вы можете создать задачу на {0} проекта, и, возможно, кто-то другой ее реализует.",
answer7_p1_link: "странице GitHub",
answer7_p2: "Другой способ - реализовать функцию самостоятельно и отправить пул реквест. Я приветствую сторонний вклад в проект.",
question8_h3: "Приложение ГУТ.Расписание больше не работает. Его починят?",
answer8_p1: "Приложение ГУТ.Расписание больше не поддерживается. Этот проект является его преемником.",
answer8_p2: "Тем не менее, ГУТ.Расписание также имеет открытый исходный код, поэтому, если вы хотите с ним поэкспериментировать, вы можете найти его {0}.",
answer8_p2_link: "на GitHub",
// DedicatedView.tsx
dedicated_h2: "Посвящается памяти научно-образовательного центра \"ТИОС\"",
dedicated_p: "Навсегда в наших сердцах ❤️",
// FooterView.tsx
footer_p1: "Сделано с ☕ и ❤️,{0}{1}",
footer_p2: "Евгений Лис"
}
});
export default strings;
+37
View File
@@ -0,0 +1,37 @@
import { Body1, Body2, makeStyles, tokens } from "@fluentui/react-components";
import type { ReactElement } from "react";
import strings from "../utils/strings";
export default function DedicatedView(): ReactElement
{
const cls = useStyles();
return (
<section className={ cls.root }>
<Body2 as="h2" align="center">{ strings.dedicated_h2 }</Body2>
<Body1 as="p" align="center">{ strings.dedicated_p }</Body1>
<a href="https://www.sut.ru/bonchnews/science/07-11-2022-pobedy-studentov-i-aspirantov-spbgut-na-radiofeste-2022" target="_blank" rel="noreferrer">
<img src="/tios.jpg" className={ cls.image } />
</a>
</section>
);
}
const useStyles = makeStyles({
root:
{
display: "flex",
boxSizing: "border-box",
flexFlow: "column",
alignItems: "center",
gap: tokens.spacingVerticalL,
padding: `200px ${tokens.spacingHorizontalM}`
},
image:
{
width: "100%",
maxWidth: "600px",
borderRadius: tokens.borderRadiusMedium,
boxShadow: tokens.shadow16
}
});
+32
View File
@@ -0,0 +1,32 @@
import { makeStyles, tokens } from "@fluentui/react-components";
const useStyles_FaqView = makeStyles({
root:
{
display: "flex",
flexFlow: "column",
justifyContent: "center",
gap: tokens.spacingVerticalXXXL,
width: "100%",
maxWidth: "1200px",
padding: `${tokens.spacingVerticalS} ${tokens.spacingVerticalM}`,
userSelect: "text",
boxSizing: "border-box",
marginBottom: tokens.spacingVerticalXXXL
},
question:
{
display: "flex",
flexFlow: "column",
gap: tokens.spacingVerticalM
},
answer:
{
display: "flex",
flexFlow: "column",
gap: tokens.spacingVerticalSNudge,
padding: `${tokens.spacingVerticalNone} ${tokens.spacingHorizontalM}`
}
});
export default useStyles_FaqView;
+97
View File
@@ -0,0 +1,97 @@
import { Body1, Subtitle1, Title3 } from "@fluentui/react-components";
import type { ReactElement } from "react";
import useStyles_FaqView from "./FaqView.styles";
import extLink from "../utils/extLink";
import strings from "../utils/strings";
const GITHUB_REPO = "https://github.com/xfox111/bonch-calendar";
const GITHUB_ISSUES = "https://github.com/xfox111/bonch-calendar/issues";
const GOOGLE_HELP = "https://support.google.com/calendar/answer/37100";
const OUTLOOK_HELP = "https://support.microsoft.com/office/import-or-subscribe-to-a-calendar-in-outlook-com-or-outlook-on-the-web-cff1429c-5af6-41ec-a5b4-74f2c278e98c";
const APPLE_HELP = "https://support.apple.com/en-us/102301";
const EMAIL = "mailto:feedback@xfox111.net";
const GUT_SCHEDULE_REPO = "https://github.com/xfox111/GUTSchedule";
export default function FaqView(): ReactElement
{
const cls = useStyles_FaqView();
return (
<section className={ cls.root }>
<Title3 align="center" as="h2" id="faq">{ strings.faq_h2 }</Title3>
<div id="faq1" className={ cls.question }>
<Subtitle1 as="h3">{ strings.question1_h3 }</Subtitle1>
<div className={ cls.answer }>
<Body1 as="p">{ strings.answer1_p1 }</Body1>
<ul>
<li>{ extLink(GOOGLE_HELP, strings.answer1_li1) }</li>
<li>{ extLink(OUTLOOK_HELP, strings.answer1_li2) }</li>
<li>{ extLink(APPLE_HELP, strings.answer1_li3) }</li>
</ul>
<Body1 as="p">{ strings.answer1_p2 }</Body1>
</div>
</div>
<div id="faq2" className={ cls.question }>
<Subtitle1 as="h3">{ strings.question2_h3 }</Subtitle1>
<div className={ cls.answer }>
<Body1 as="p">{ strings.answer2_p1 }</Body1>
</div>
</div>
<div id="faq3" className={ cls.question }>
<Subtitle1 as="h3">{ strings.question3_h3 }</Subtitle1>
<div className={ cls.answer }>
<Body1 as="p">{ strings.answer3_p1 }</Body1>
<Body1 as="p">{ strings.answer3_p2 }</Body1>
</div>
</div>
<div id="faq4" className={ cls.question }>
<Subtitle1 as="h3">{ strings.question4_h3 }</Subtitle1>
<div className={ cls.answer }>
<Body1 as="p">{ strings.answer4_p1 }</Body1>
<Body1 as="p">{ strings.answer4_p2 }</Body1>
</div>
</div>
<div id="faq5" className={ cls.question }>
<Subtitle1 as="h3">{ strings.question5_h3 }</Subtitle1>
<div className={ cls.answer }>
<Body1 as="p">{ strings.answer5_p1 }</Body1>
<Body1 as="p">{ strings.answer5_p2 }</Body1>
</div>
</div>
<div id="faq6" className={ cls.question }>
<Subtitle1 as="h3">{ strings.question6_h3 }</Subtitle1>
<div className={ cls.answer }>
<Body1 as="p">
{ strings.formatString(
strings.answer6_p1,
extLink(GITHUB_ISSUES, strings.answer6_p1_link1),
extLink(EMAIL, strings.answer6_p1_link2)
) }
</Body1>
<Body1 as="p">
{ strings.formatString(strings.answer6_p2, extLink(GITHUB_REPO, strings.answer6_p2_link)) }
</Body1>
</div>
</div>
<div id="faq7" className={ cls.question }>
<Subtitle1 as="h3">{ strings.question7_h3 }</Subtitle1>
<div className={ cls.answer }>
<Body1 as="p">
{ strings.formatString(strings.answer7_p1, extLink(GITHUB_REPO, strings.answer7_p1_link)) }
</Body1>
<Body1 as="p">{ strings.answer7_p2}</Body1>
</div>
</div>
<div id="faq8" className={ cls.question }>
<Subtitle1 as="h3">{ strings.question8_h3 }</Subtitle1>
<div className={ cls.answer }>
<Body1 as="p">{ strings.answer8_p1 }</Body1>
<Body1 as="p">
{ strings.formatString(strings.answer8_p2, extLink(GUT_SCHEDULE_REPO, strings.answer8_p2_link)) }
</Body1>
</div>
</div>
</section>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Body1, makeStyles } from "@fluentui/react-components";
import type { ReactElement } from "react";
import extLink from "../utils/extLink";
import strings from "../utils/strings";
const MY_WEBSITE = "https://xfox111.net";
export default function FooterView(): ReactElement
{
const cls = useStyles();
return (
<footer className={ cls.root }>
<div className={ cls.imageContainer }>
<Body1 as="p" className={ cls.caption }>
{strings.formatString(strings.footer_p1, <br />, extLink(MY_WEBSITE, strings.footer_p2))}
</Body1>
<img src="/footer.svg" />
</div>
</footer>
);
}
const useStyles = makeStyles({
root:
{
width: "100%",
display: "flex",
justifyContent: "flex-end",
alignItems: "end"
},
imageContainer:
{
position: "relative",
width: "100%",
maxWidth: "400px",
},
caption:
{
position: "absolute",
top: "24px",
left: "72px",
}
});
+81
View File
@@ -0,0 +1,81 @@
import { makeStyles, tokens, shorthands } from "@fluentui/react-components";
const useStyles_MainView = makeStyles({
root:
{
display: "flex",
flexFlow: "column",
gap: tokens.spacingVerticalXXXL,
justifyContent: "center",
minHeight: "90vh",
alignItems: "center",
padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalL}`,
"& p":
{
textAlign: "center"
}
},
highlight:
{
color: tokens.colorBrandForeground1
},
courseButton:
{
minWidth: "48px",
borderRadius: tokens.borderRadiusNone,
borderRightWidth: 0,
"&:first-of-type":
{
borderStartStartRadius: tokens.borderRadiusCircular,
borderEndStartRadius: tokens.borderRadiusCircular
},
"&:last-of-type":
{
borderStartEndRadius: tokens.borderRadiusCircular,
borderEndEndRadius: tokens.borderRadiusCircular,
borderRightWidth: tokens.strokeWidthThin,
},
},
stack:
{
display: "flex",
flexFlow: "column",
alignItems: "center",
gap: tokens.spacingVerticalSNudge
},
form:
{
gap: tokens.spacingVerticalL
},
copiedStyle:
{
color: tokens.colorStatusSuccessForeground1 + " !important",
...shorthands.borderColor(tokens.colorStatusSuccessBorder1 + " !important")
},
field:
{
width: "250px"
},
copyIcon:
{
animationName: "scaleUpFade",
animationDuration: tokens.durationFast,
animationTimingFunction: tokens.curveEasyEaseMax
},
hidden:
{
pointerEvents: "none"
},
truncatedText:
{
overflowX: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}
});
export default useStyles_MainView;
+167
View File
@@ -0,0 +1,167 @@
import { LargeTitle, Subtitle1, Label, Dropdown, Button, Subtitle2, Body1, Option } from "@fluentui/react-components";
import { mergeClasses, useArrowNavigationGroup } from "@fluentui/react-components";
import type { SelectionEvents, OptionOnSelectData } from "@fluentui/react-components";
import { Copy24Regular, ArrowDownload24Regular, Checkmark24Regular } from "@fluentui/react-icons";
import { Slide, Stagger } from "@fluentui/react-motion-components-preview";
import { use, useCallback, useMemo, useState, type ReactElement } from "react";
import useTimeout from "../hooks/useTimeout";
import useStyles_MainView from "./MainView.styles";
import { fetchFaculties, fetchGroups } from "../utils/api";
import strings from "../utils/strings";
const facultiesPromise = fetchFaculties();
const getEntryOrEmpty = (entries: [string, string][], key: string): string =>
entries.find(i => i[0] === key)?.[1] ?? "";
export default function MainView(): ReactElement
{
const faculties: [string, string][] = use(facultiesPromise);
const [facultyId, setFacultyId] = useState<string>("");
const courses: number[] = useMemo(() => facultyId == "56682" ? [1, 2] : [1, 2, 3, 4, 5], [facultyId]);
const [course, setCourse] = useState<number>(0);
const [groups, setGroups] = useState<[string, string][] | null>(null);
const [groupId, setGroupId] = useState<string>("");
const icalUrl = useMemo(() => `${import.meta.env.VITE_BACKEND_HOST}/timetable/${facultyId}/${groupId}`, [groupId, facultyId]);
const [showCta, setShowCta] = useState<boolean>(false);
const [copyActive, triggerCopy] = useTimeout(3000);
const navAttributes = useArrowNavigationGroup({ axis: "horizontal" });
const cls = useStyles_MainView();
const copyLink = useCallback((): void =>
{
navigator.clipboard.writeText(icalUrl);
triggerCopy();
setShowCta(true);
}, [icalUrl, triggerCopy]);
const onFacultySelect = useCallback((_: SelectionEvents, data: OptionOnSelectData): void =>
{
if (data.optionValue === facultyId)
return;
setFacultyId(data.optionValue!);
setCourse(0);
setGroupId("");
setGroups(null);
}, [facultyId]);
const onCourseSelect = useCallback((courseNumber: number): void =>
{
if (courseNumber === course)
return;
setCourse(courseNumber);
setGroupId("");
setGroups(null);
fetchGroups(facultyId, courseNumber).then(setGroups);
}, [course, facultyId]);
return (
<section className={ cls.root }>
<header className={ cls.stack }>
<LargeTitle as="h1">
{ strings.formatString(strings.title_p1, <span className={ cls.highlight }>{ strings.title_p2 }</span>) }
</LargeTitle>
<Subtitle1 as="p">
{ strings.formatString(strings.subtitle_p1, <span className={ cls.highlight }>{ strings.subtitle_p2 }</span>) }
</Subtitle1>
</header>
<div className={ mergeClasses(cls.stack, cls.form) }>
<Slide visible appear>
<div className={ cls.stack }>
<Label htmlFor="faculty">{ strings.pickFaculty }</Label>
<Dropdown id="faculty"
value={ getEntryOrEmpty(faculties, facultyId) }
onOptionSelect={ onFacultySelect }
className={ cls.field }
positioning={ { pinned: true, position: "below" } }
button={
<span className={ cls.truncatedText }>{ getEntryOrEmpty(faculties, facultyId) }</span>
}>
{ faculties.map(([id, name]) =>
<Option key={ id } value={ id }>{ name }</Option>
) }
</Dropdown>
</div>
</Slide>
<Slide visible={ facultyId !== "" }>
<div className={ mergeClasses(cls.stack, facultyId === "" && cls.hidden) }>
<Label>{ strings.pickCourse }</Label>
<div { ...navAttributes }>
{ courses.map(i =>
<Button key={ i }
className={ cls.courseButton }
appearance={ course === i ? "primary" : "secondary" }
onClick={ () => onCourseSelect(i) }>
{ i }
</Button>
) }
</div>
</div>
</Slide>
<Slide visible={ course !== 0 && groups !== null }>
<div className={ mergeClasses(cls.stack, course === 0 && cls.hidden) }>
<Label as="label" htmlFor="group">{ strings.pickGroup }</Label>
<Dropdown id="group"
className={ cls.field }
positioning={ { pinned: true, position: "below" } }
value={ getEntryOrEmpty(groups ?? [], groupId) }
onOptionSelect={ (_, e) => setGroupId(e.optionValue!) }>
{ groups?.map(([id, name]) =>
<Option key={ id } value={ id }>{ name }</Option>
) }
{ (groups?.length ?? 0) < 1 &&
<Option disabled>{ strings.pickGroup_empty }</Option>
}
</Dropdown>
</div>
</Slide>
</div>
<div className={ cls.stack }>
<Stagger visible={ groupId !== "" }>
<Slide>
<div className={ mergeClasses(cls.stack, groupId === "" && cls.hidden) }>
<Subtitle2>{ strings.subscribe }</Subtitle2>
<Button
onClick={ copyLink }
className={ mergeClasses(cls.field, copyActive && cls.copiedStyle) }
iconPosition="after"
title={ strings.copy }
icon={ copyActive
? <Checkmark24Regular className={ cls.copyIcon } />
: <Copy24Regular className={ cls.copyIcon } />
}>
<span className={ cls.truncatedText }>{ icalUrl }</span>
</Button>
</div>
</Slide>
<Slide>
<div className={ mergeClasses(cls.stack, groupId === "" && cls.hidden) }>
<Body1>{ strings.or }</Body1>
<Button as="a"
appearance="subtle" icon={ <ArrowDownload24Regular /> }
onClick={ () => setShowCta(true) }
href={ icalUrl }>
{ strings.download }
</Button>
</div>
</Slide>
</Stagger>
</div>
<Slide visible={ showCta }>
<Subtitle2 as="p">{ strings.cta }</Subtitle2>
</Slide>
</section>
);
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": [
"vite/client"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": [
"ES2023"
],
"module": "ESNext",
"types": [
"node"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"vite.config.ts"
]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+18
View File
@@ -0,0 +1,18 @@
services:
api:
image: xfox111/bonch-calendar-api:latest
build:
context: ./api
ports:
- 8080:8080
app:
image: xfox111/bonch-calendar-app:latest
build:
context: ./app
args:
- API_HOST=http://localhost:8080
ports:
- 8000:80
depends_on:
- api