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