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:
@@ -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?
|
||||
@@ -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?
|
||||
@@ -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;"]
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
]);
|
||||
@@ -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>
|
||||
Generated
+5255
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user