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
+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()],
});