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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user