├── Makefile ├── .stylelintignore ├── src ├── vite-env.d.ts ├── api │ ├── controllers │ │ ├── auth │ │ │ ├── index.ts │ │ │ └── decorators.ts │ │ ├── index.ts │ │ ├── UserdataApi.ts │ │ └── TimetableApi.ts │ ├── index.ts │ ├── types.ts │ └── client.ts ├── api_switch.jpg ├── utils │ ├── utils.ts │ ├── achievement.ts │ ├── authMethodName.ts │ ├── time.ts │ ├── personName.ts │ ├── utils.test.ts │ ├── time.test.ts │ ├── date.ts │ ├── personName.test.ts │ ├── date.test.ts │ └── UserdataConverter.test.ts ├── api_entities.jpg ├── src_api_cats.jpg ├── public │ ├── favicon.png │ ├── apple-touch-icon.webp │ ├── icons │ │ ├── icon_120x120.webp │ │ ├── icon_144x144.webp │ │ ├── icon_152x152.webp │ │ ├── icon_167x167.webp │ │ ├── icon_180x180.webp │ │ ├── icon_192x192.webp │ │ ├── icon_36x36.webp │ │ ├── icon_48x48.webp │ │ ├── icon_512x512.webp │ │ ├── icon_72x72.webp │ │ ├── icon_96x96.webp │ │ ├── icon_1024x1024.png │ │ ├── icon_1024x1024.webp │ │ └── maskable │ │ │ ├── icon_36x36.webp │ │ │ ├── icon_48x48.webp │ │ │ ├── icon_72x72.webp │ │ │ ├── icon_96x96.webp │ │ │ ├── icon_144x144.webp │ │ │ ├── icon_192x192.webp │ │ │ ├── icon_512x512.webp │ │ │ └── icon_1024x1024.webp │ ├── reset.html │ └── icon.svg ├── swagger_select.jpg ├── assets │ ├── map_background.webp │ ├── lecturer_placeholder.webp │ ├── profile_image_placeholder.webp │ ├── logo │ │ ├── Desktop.svg │ │ ├── Tablet.svg │ │ ├── Apple.svg │ │ ├── Yandex.svg │ │ ├── Ipad.svg │ │ ├── Iphone.svg │ │ ├── Chrome.svg │ │ ├── Mobile.svg │ │ └── Android.svg │ ├── unexpected_error.svg │ ├── forbidden.svg │ ├── not_found.svg │ └── network_error.svg ├── index.css ├── views │ ├── error │ │ ├── UnexpectedError.vue │ │ ├── Error404View.vue │ │ ├── Error403View.vue │ │ ├── NetworkError.vue │ │ └── ErrorLayout.vue │ ├── profile │ │ ├── sessions │ │ │ ├── ProfileSessionsView.vue │ │ │ └── AsyncContent.vue │ │ ├── achievement │ │ │ ├── AchievementsElement.vue │ │ │ └── AchievementsSlider.vue │ │ ├── ProfileDeleteView.vue │ │ ├── ProfileEditAuthView.vue │ │ └── ProfileSettingsView.vue │ ├── auth │ │ ├── LoginErrorView.vue │ │ ├── ResetEmail.vue │ │ ├── ChangeEmailView.vue │ │ ├── ChangePasswordView.vue │ │ ├── OauthRegisterView.vue │ │ ├── AddEmailView.vue │ │ ├── ResetPassword.vue │ │ └── AuthView.vue │ ├── timetable │ │ ├── room │ │ │ ├── AsyncRoomSchedule.vue │ │ │ ├── AsyncRoomInfo.vue │ │ │ └── TimetableRoomView.vue │ │ ├── lecturer │ │ │ ├── AsyncLecturerSchedule.vue │ │ │ ├── TimetableLecturerView.vue │ │ │ └── AsyncLecturerInfo.vue │ │ ├── event │ │ │ ├── TimetableEventView.vue │ │ │ └── AsyncContent.vue │ │ ├── AsyncEventsList.vue │ │ ├── init │ │ │ ├── GroupsListItem.vue │ │ │ ├── AsyncGroupsList.vue │ │ │ └── TimetableInitView.vue │ │ ├── DateNavigation.vue │ │ ├── TimetableView.vue │ │ └── CalendarDropdown.vue │ ├── apps │ │ └── AppsView.vue │ └── admin │ │ ├── users │ │ └── AdminUsersView.vue │ │ ├── UsersTable.vue │ │ ├── ScopesTable.vue │ │ ├── achievement │ │ ├── AchievementRow.vue │ │ └── AchievementRecieversView.vue │ │ ├── scopes │ │ └── AdminScopesView.vue │ │ ├── groups │ │ ├── AdminGroupsView.vue │ │ └── GroupTreeNode.vue │ │ └── group │ │ └── AdminGroupView.vue ├── components │ ├── AccessRestricted.vue │ ├── IrdomLayout.vue │ ├── IrdomToastList.vue │ ├── FullscreenLoader.vue │ ├── IrdomNavbar.vue │ ├── LoginForm.vue │ ├── ForgotPassordForm.vue │ ├── IrdomToast.vue │ ├── MarkdownRenderer.vue │ ├── RegistrationForm.vue │ ├── EventRow.vue │ ├── DataRow.vue │ ├── IrdomAuthButton.vue │ ├── IrdomToolbar.vue │ └── EmailPasswordForm.vue ├── constants │ ├── navbarItems.ts │ └── authButtons.ts ├── main.ts ├── store │ ├── toast.ts │ ├── toolbar.ts │ ├── apps.ts │ ├── timetable.ts │ └── profile.ts ├── models │ ├── ScopeName.ts │ └── LocalStorage.ts ├── router │ ├── admin.ts │ ├── timetable.ts │ ├── profile.ts │ ├── index.ts │ └── auth.ts ├── App.vue └── vuetify.ts ├── timetable_init.jpg ├── .editorconfig ├── .prettierignore ├── vitePwaOptions.ts ├── .prettierrc.json ├── tsconfig.node.json ├── .env.production ├── .env.development ├── .env.testing ├── .stylelintrc.json ├── deployment ├── docker_entrypoint.sh ├── Dockerfile └── nginx.conf ├── .gitignore ├── tsconfig.json ├── vite.config.ts ├── eslint.config.js ├── .github └── workflows │ ├── pr_create.yml │ └── main_commit.yml ├── index.html ├── LICENSE ├── webapp-ui.code-workspace ├── package.json └── vitePwaManifest.ts /Makefile: -------------------------------------------------------------------------------- 1 | format: 2 | pnpm run format 3 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .vite -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/api/controllers/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthApi } from './AuthApi'; 2 | -------------------------------------------------------------------------------- /src/api_switch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/api_switch.jpg -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const lz = (v: number) => `0${v}`.slice(-2); // leading zero 2 | -------------------------------------------------------------------------------- /timetable_init.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/timetable_init.jpg -------------------------------------------------------------------------------- /src/api_entities.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/api_entities.jpg -------------------------------------------------------------------------------- /src/src_api_cats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/src_api_cats.jpg -------------------------------------------------------------------------------- /src/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/favicon.png -------------------------------------------------------------------------------- /src/swagger_select.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/swagger_select.jpg -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 2 7 | -------------------------------------------------------------------------------- /src/assets/map_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/assets/map_background.webp -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export { apiClient } from './client'; 2 | export * from './types'; 3 | export * from './controllers'; 4 | -------------------------------------------------------------------------------- /src/public/apple-touch-icon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/apple-touch-icon.webp -------------------------------------------------------------------------------- /src/public/icons/icon_120x120.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_120x120.webp -------------------------------------------------------------------------------- /src/public/icons/icon_144x144.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_144x144.webp -------------------------------------------------------------------------------- /src/public/icons/icon_152x152.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_152x152.webp -------------------------------------------------------------------------------- /src/public/icons/icon_167x167.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_167x167.webp -------------------------------------------------------------------------------- /src/public/icons/icon_180x180.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_180x180.webp -------------------------------------------------------------------------------- /src/public/icons/icon_192x192.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_192x192.webp -------------------------------------------------------------------------------- /src/public/icons/icon_36x36.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_36x36.webp -------------------------------------------------------------------------------- /src/public/icons/icon_48x48.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_48x48.webp -------------------------------------------------------------------------------- /src/public/icons/icon_512x512.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_512x512.webp -------------------------------------------------------------------------------- /src/public/icons/icon_72x72.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_72x72.webp -------------------------------------------------------------------------------- /src/public/icons/icon_96x96.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_96x96.webp -------------------------------------------------------------------------------- /src/assets/lecturer_placeholder.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/assets/lecturer_placeholder.webp -------------------------------------------------------------------------------- /src/public/icons/icon_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_1024x1024.png -------------------------------------------------------------------------------- /src/public/icons/icon_1024x1024.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/icon_1024x1024.webp -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | a { 2 | text-decoration: none; 3 | color: inherit; 4 | } 5 | 6 | svg { 7 | fill: currentcolor; 8 | } 9 | -------------------------------------------------------------------------------- /src/assets/profile_image_placeholder.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/assets/profile_image_placeholder.webp -------------------------------------------------------------------------------- /src/public/icons/maskable/icon_36x36.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/maskable/icon_36x36.webp -------------------------------------------------------------------------------- /src/public/icons/maskable/icon_48x48.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/maskable/icon_48x48.webp -------------------------------------------------------------------------------- /src/public/icons/maskable/icon_72x72.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/maskable/icon_72x72.webp -------------------------------------------------------------------------------- /src/public/icons/maskable/icon_96x96.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/maskable/icon_96x96.webp -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | deployment/ 3 | dist/ 4 | node_modules/ 5 | pnpm-lock.yaml 6 | .vite 7 | package.json 8 | bun.lockb 9 | -------------------------------------------------------------------------------- /src/public/icons/maskable/icon_144x144.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/maskable/icon_144x144.webp -------------------------------------------------------------------------------- /src/public/icons/maskable/icon_192x192.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/maskable/icon_192x192.webp -------------------------------------------------------------------------------- /src/public/icons/maskable/icon_512x512.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/maskable/icon_512x512.webp -------------------------------------------------------------------------------- /src/public/icons/maskable/icon_1024x1024.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profcomff/webapp-ui/HEAD/src/public/icons/maskable/icon_1024x1024.webp -------------------------------------------------------------------------------- /src/utils/achievement.ts: -------------------------------------------------------------------------------- 1 | export function getPictureUrl(picUrl: string | null) { 2 | return import.meta.env.VITE_API_URL + '/achievement/' + picUrl; 3 | } 4 | -------------------------------------------------------------------------------- /src/api/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export { TimetableApi } from './TimetableApi'; 3 | export { UserdataApi } from './UserdataApi'; 4 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | export type ApiError = { 2 | status: string; 3 | message: string; 4 | ru: string; 5 | }; 6 | 7 | export interface ErrorInfo { 8 | url: string; 9 | status: number; 10 | message: string; 11 | } 12 | -------------------------------------------------------------------------------- /vitePwaOptions.ts: -------------------------------------------------------------------------------- 1 | import { VitePWAOptions } from 'vite-plugin-pwa'; 2 | import { vitePwaManifest } from './vitePwaManifest'; 3 | 4 | export const vitePWAconfig: Partial = { 5 | manifest: vitePwaManifest, 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "arrowParens": "avoid", 4 | "bracketSpacing": true, 5 | "useTabs": true, 6 | "singleQuote": true, 7 | "printWidth": 100, 8 | "semi": true, 9 | "endOfLine": "auto" 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/authMethodName.ts: -------------------------------------------------------------------------------- 1 | import { AuthMethodLink, AuthMethodLinkList } from '@/models'; 2 | 3 | export function isAuthMethod(authMethod: string | string[]): authMethod is AuthMethodLink { 4 | return AuthMethodLinkList.includes(authMethod as AuthMethodLink); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/time.ts: -------------------------------------------------------------------------------- 1 | import { lz } from './utils'; 2 | 3 | export const formatTime = (ts: number | string) => { 4 | const date = new Date(ts); 5 | return `${lz(date.getHours())}:${lz(date.getMinutes())}`; 6 | }; 7 | 8 | export const msInHour = 60 * 60 * 1000; 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts", "vitePwaManifest.ts", "vitePwaOptions.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_CDN_URL=https://cdn.profcomff.com 2 | BASE_URL=https://app.profcomff.com 3 | VITE_API_URL=https://api.profcomff.com 4 | VITE_FEEDBACK_URL=https://forms.yandex.ru/u/635d013b068ff0587320bfc9/ 5 | VITE_AUTH_TELEGRAM_BOT=com_profcomff_app_bot 6 | VITE_AUTH_REDIRECT_URL=https://app.profcomff.com 7 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | VITE_CDN_URL=https://cdn.test.profcomff.com 2 | BASE_URL=https://app.test.profcomff.com 3 | VITE_API_URL=https://api.test.profcomff.com 4 | VITE_FEEDBACK_URL=https://forms.yandex.ru/u/635d013b068ff0587320bfc9/ 5 | VITE_AUTH_TELEGRAM_BOT=com_profcomff_app_test_bot 6 | VITE_AUTH_REDIRECT_URL=http://localhost:9000 7 | -------------------------------------------------------------------------------- /.env.testing: -------------------------------------------------------------------------------- 1 | VITE_CDN_URL=https://cdn.test.profcomff.com 2 | BASE_URL=https://app.test.profcomff.com 3 | VITE_API_URL=https://api.test.profcomff.com 4 | VITE_FEEDBACK_URL=https://forms.yandex.ru/u/635d013b068ff0587320bfc9/ 5 | VITE_AUTH_TELEGRAM_BOT=com_profcomff_app_test_bot 6 | VITE_AUTH_REDIRECT_URL=https://app.test.profcomff.com 7 | -------------------------------------------------------------------------------- /src/assets/logo/Desktop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard", "stylelint-config-recommended-vue"], 3 | "rules": { 4 | "function-disallowed-list": ["rgba", "hsla", "hsl"], 5 | "color-function-notation": "modern", 6 | "color-no-hex": null, 7 | "selector-nested-pattern": "^&", 8 | "selector-class-pattern": null 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/logo/Tablet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/personName.ts: -------------------------------------------------------------------------------- 1 | interface GetNameWithInitialsArgs { 2 | first_name: string; 3 | middle_name: string; 4 | last_name: string; 5 | } 6 | 7 | export const getNameWithInitials = ({ 8 | first_name, 9 | middle_name, 10 | last_name, 11 | }: GetNameWithInitialsArgs) => { 12 | return `${last_name} ${first_name[0]}. ${middle_name[0]}.`; 13 | }; 14 | -------------------------------------------------------------------------------- /src/views/error/UnexpectedError.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | Непредвиденная ошибка 9 | 10 | 11 | -------------------------------------------------------------------------------- /deployment/docker_entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export APPJS_NAME=$(cd /usr/share/nginx/html && ls js/app.*.js) 3 | if [ ! -z "${APPJS_NAME}" ] 4 | then 5 | echo "app.js is ${APPJS_NAME}" 6 | sed -i "s|APPJS_NAME|'${APPJS_NAME}'|g" /etc/nginx/conf.d/default.conf 7 | sed -i 's|# ||g' /etc/nginx/conf.d/default.conf 8 | else 9 | echo 'app.js is app.js, no changes' 10 | fi 11 | -------------------------------------------------------------------------------- /src/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { lz } from './utils'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('Leading-zero add function:', () => { 5 | it('should add leading zero to one-digit numbers', () => { 6 | expect(lz(4)).toBe('04'); 7 | }); 8 | 9 | it('should not add leading zero to two-digit numbers', () => { 10 | expect(lz(10)).toBe('10'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/AccessRestricted.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/views/error/Error404View.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | Страница не найдена 9 | Поищите еще! 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/views/error/Error403View.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | О нет… 9 | Доступ к этой странице запрещен 10 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # 27 | .stylelintcache 28 | .vite 29 | -------------------------------------------------------------------------------- /src/views/error/NetworkError.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | Эта страница недоступна без Интернета 9 | Пропало подключение к сети 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/logo/Apple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/logo/Yandex.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/time.test.ts: -------------------------------------------------------------------------------- 1 | import { formatTime } from './time'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | describe('Time format function:', () => { 5 | it('should format timestamp to hh:mm', () => { 6 | expect(formatTime(new Date(2023, 13, 2, 0, 0).getTime())).toBe('00:00'); 7 | expect(formatTime(new Date(2023, 13, 2, 13, 29).getTime())).toBe('13:29'); 8 | expect(formatTime(new Date(2023, 13, 2, 23, 59).getTime())).toBe('23:59'); 9 | expect(formatTime(new Date(2023, 13, 2, 24, 0).getTime())).toBe('00:00'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/constants/navbarItems.ts: -------------------------------------------------------------------------------- 1 | import { NavbarItem } from '@/components/IrdomNavbar.vue'; 2 | 3 | const profile: NavbarItem = { 4 | icon: 'account_circle', 5 | name: 'Профиль', 6 | path: '/profile', 7 | }; 8 | 9 | export const navbarItems: Record = { 10 | '/timetable': { 11 | icon: 'calendar_month', 12 | name: 'Расписание', 13 | path: '/timetable', 14 | }, 15 | '/apps': { 16 | icon: 'dashboard', 17 | name: 'Сервисы', 18 | path: '/apps', 19 | }, 20 | '/profile': profile, 21 | '/auth': profile, 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { lz } from './utils'; 2 | 3 | export const getWeekdayName = (date: Date, weekday: 'short' | 'long' = 'short') => 4 | date.toLocaleString('ru-RU', { weekday }); 5 | 6 | export const getDateWithDayOffset = (date: Date, offset: number) => { 7 | const d = new Date(date); 8 | d.setDate(date.getDate() + offset); 9 | 10 | return d; 11 | }; 12 | 13 | export const stringifyDate = (date: Date) => { 14 | const day = date.getDate(); 15 | const month = date.getMonth() + 1; 16 | const year = date.getFullYear(); 17 | return `${year}-${lz(month)}-${lz(day)}`; 18 | }; 19 | -------------------------------------------------------------------------------- /src/assets/logo/Ipad.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/IrdomLayout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 30 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './App.vue'; 2 | import router from './router'; 3 | 4 | import './index.css'; 5 | import 'vuetify/styles'; 6 | 7 | import { createApp } from 'vue'; 8 | import { createPinia } from 'pinia'; 9 | import { registerSW } from 'virtual:pwa-register'; 10 | import { vuetify } from './vuetify'; 11 | const pinia = createPinia(); 12 | 13 | const updateSW = registerSW({ 14 | onNeedRefresh() { 15 | const isUpdate = confirm('Доступна новая версия приложения! Обновить?'); 16 | if (isUpdate) { 17 | updateSW(); 18 | } 19 | }, 20 | }); 21 | 22 | createApp(App).use(router).use(pinia).use(vuetify).mount('#app'); 23 | -------------------------------------------------------------------------------- /src/store/toast.ts: -------------------------------------------------------------------------------- 1 | import { Toast } from '@/models'; 2 | import { defineStore } from 'pinia'; 3 | import { ref } from 'vue'; 4 | 5 | export const useToastStore = defineStore('toast', () => { 6 | const list = ref>(new Map()); 7 | 8 | function push(toast: Toast, timeout: number | null = 3000) { 9 | const id = Math.round(Number.MAX_SAFE_INTEGER * Math.random()); 10 | list.value.set(id, toast); 11 | if (timeout !== null) { 12 | setTimeout(() => remove(id), timeout); 13 | } 14 | } 15 | 16 | function remove(id: number) { 17 | list.value.delete(id); 18 | } 19 | 20 | return { list, push, remove }; 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/personName.test.ts: -------------------------------------------------------------------------------- 1 | import { getNameWithInitials } from './personName'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | describe('Format person name function: ', () => { 5 | it('should format full names', () => { 6 | expect( 7 | getNameWithInitials({ 8 | first_name: 'Artem', 9 | last_name: 'Netsvetaev', 10 | middle_name: 'Andreevich', 11 | }) 12 | ).toBe('Netsvetaev A. A.'); 13 | }); 14 | 15 | it('should format short names', () => { 16 | expect( 17 | getNameWithInitials({ first_name: 'A.', last_name: 'Netsvetaev', middle_name: 'A.' }) 18 | ).toBe('Netsvetaev A. A.'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/assets/logo/Iphone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/profile/sessions/ProfileSessionsView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /deployment/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 AS build 2 | 3 | ARG BUILD_MODE=production 4 | ARG LAUNCH_MODE=production 5 | ARG APP_VERSION=dev 6 | 7 | ENV MODE=${LAUNCH_MODE} 8 | ENV VITE_APP_VERSION=${APP_VERSION} 9 | 10 | WORKDIR /app 11 | ADD ./package.json ./pnpm-lock.yaml /app/ 12 | RUN npm i -g pnpm && pnpm install 13 | ADD . /app 14 | RUN pnpm build --mode ${BUILD_MODE} 15 | 16 | 17 | FROM nginx:1.21 18 | 19 | ADD ./deployment/nginx.conf /etc/nginx/conf.d/default.conf 20 | ADD ./deployment/docker_entrypoint.sh /docker_entrypoint.sh 21 | COPY --from=build /app/dist /usr/share/nginx/html 22 | 23 | RUN chmod +x /docker_entrypoint.sh && /docker_entrypoint.sh 24 | -------------------------------------------------------------------------------- /src/components/IrdomToastList.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | 19 | 31 | -------------------------------------------------------------------------------- /src/public/reset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "lib": ["ESNext", "DOM"], 13 | "skipLibCheck": true, 14 | "noEmit": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@/*": ["src/*"] 19 | }, 20 | "types": ["vite-plugin-pwa/client"] 21 | }, 22 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 23 | "references": [ 24 | { 25 | "path": "./tsconfig.node.json" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/components/FullscreenLoader.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 37 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePWAconfig } from './vitePwaOptions'; 2 | import { defineConfig } from 'vite'; 3 | import { fileURLToPath, URL } from 'url'; 4 | import { VitePWA } from 'vite-plugin-pwa'; 5 | import Vue from '@vitejs/plugin-vue'; 6 | import postcssPresetEnv from 'postcss-preset-env'; 7 | import vuetify from 'vite-plugin-vuetify'; 8 | 9 | export default defineConfig({ 10 | css: { 11 | postcss: { 12 | plugins: [ 13 | postcssPresetEnv({ 14 | features: { 15 | 'nesting-rules': true, 16 | }, 17 | }), 18 | ], 19 | }, 20 | }, 21 | plugins: [Vue(), vuetify(), VitePWA(vitePWAconfig)], 22 | resolve: { 23 | alias: [{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }], 24 | }, 25 | publicDir: 'src/public', 26 | }); 27 | -------------------------------------------------------------------------------- /src/views/auth/LoginErrorView.vue: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | Произошла ошибка при входе в аккаунт 18 | {{ route.query.text }} 19 | Вернуться к методам входа 22 | 23 | 24 | 25 | 30 | -------------------------------------------------------------------------------- /src/assets/logo/Chrome.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/api/controllers/UserdataApi.ts: -------------------------------------------------------------------------------- 1 | import { apply, checkToken, showErrorToast } from './auth/decorators'; 2 | import { UserdataUpdateUser } from '@/models'; 3 | import { apiClient } from '../client'; 4 | 5 | export class UserdataApi { 6 | static getUser = apply( 7 | async (id: number) => 8 | await apiClient.GET('/userdata/user/{id}', { 9 | params: { path: { id } }, 10 | }), 11 | [checkToken], 12 | [showErrorToast] 13 | ); 14 | 15 | static getCategories = apply( 16 | async () => apiClient.GET('/userdata/category'), 17 | [checkToken], 18 | [showErrorToast] 19 | ); 20 | 21 | static patchUserById = apply( 22 | async (id: number, body: UserdataUpdateUser) => 23 | await apiClient.POST('/userdata/user/{id}', { 24 | params: { path: { id } }, 25 | body, 26 | }), 27 | [checkToken], 28 | [showErrorToast] 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/models/ScopeName.ts: -------------------------------------------------------------------------------- 1 | export const scopename = Object.freeze({ 2 | auth: { 3 | group: { 4 | create: 'auth.group.create', 5 | delete: 'auth.group.delete', 6 | update: 'auth.group.update', 7 | }, 8 | scope: { 9 | create: 'auth.scope.create', 10 | delete: 'auth.scope.delete', 11 | update: 'auth.scope.delete', 12 | read: 'auth.scope.read', 13 | }, 14 | user: { 15 | delete: 'auth.user.delete', 16 | read: 'auth.user.read', 17 | update: 'auth.user.update', 18 | }, 19 | }, 20 | achievements: { 21 | achievement: { 22 | create: 'achievements.achievement.create', 23 | update: 'achievements.achievement.edit', 24 | give: 'achievements.achievement.give', 25 | revoke: 'achievements.achievement.revoke', 26 | }, 27 | }, 28 | webapp: { 29 | admin: { 30 | show: 'webapp.admin.show', 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/views/timetable/room/AsyncRoomSchedule.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 27 | В аудитории нет пар 28 | 29 | -------------------------------------------------------------------------------- /src/views/apps/AppsView.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Версия приложения: {{ version }} 25 | 26 | 27 | 28 | 34 | -------------------------------------------------------------------------------- /src/components/IrdomNavbar.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 29 | 30 | {{ button.name }} 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/views/timetable/lecturer/AsyncLecturerSchedule.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 26 | У преподавателя выходной 27 | 28 | -------------------------------------------------------------------------------- /src/models/LocalStorage.ts: -------------------------------------------------------------------------------- 1 | export enum LocalStorageItem { 2 | StudyGroup = 'timetable-group', 3 | Token = 'token', 4 | TokenScopes = 'token-scopes', 5 | MarketingId = 'marketing-id', 6 | AppToken = 'app-token', 7 | } 8 | 9 | export class LocalStorage { 10 | static set(name: LocalStorageItem, body: T) { 11 | if (typeof body === 'object') { 12 | localStorage.setItem(name, JSON.stringify(body)); 13 | } else { 14 | localStorage.setItem(name, `${body}`); 15 | } 16 | } 17 | 18 | static getObject(name: LocalStorageItem): T | null { 19 | const body = localStorage.getItem(name); 20 | return body ? JSON.parse(body) : null; 21 | } 22 | 23 | static get(name: LocalStorageItem): string | null { 24 | return localStorage.getItem(name); 25 | } 26 | 27 | static delete(...names: LocalStorageItem[]) { 28 | for (const name of names) { 29 | localStorage.removeItem(name); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/LoginForm.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | 23 | Войти 24 | Забыли пароль? 25 | 26 | 27 | -------------------------------------------------------------------------------- /deployment/nginx.conf: -------------------------------------------------------------------------------- 1 | log_format json_combined escape=json 2 | '{' 3 | '"timestamp":"$time_iso8601",' 4 | '"remote_addr":"$remote_addr",' 5 | '"remote_user":"$remote_user",' 6 | '"request":"$request",' 7 | '"status": "$status",' 8 | '"body_bytes_sent":"$body_bytes_sent",' 9 | '"request_time":"$request_time",' 10 | '"http_referrer":"$http_referer",' 11 | '"http_user_agent":"$http_user_agent"' 12 | '}'; 13 | 14 | server { 15 | access_log /dev/stdout json_combined; 16 | 17 | listen 80; 18 | listen [::]:80; 19 | server_name localhost; 20 | 21 | location / { 22 | root /usr/share/nginx/html; 23 | index index.html index.htm; 24 | try_files $uri /index.html; 25 | } 26 | 27 | error_page 500 502 503 504 /50x.html; 28 | location = /50x.html { 29 | root /usr/share/nginx/html; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/api/client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@profcomff/api-uilib'; 2 | import { type Middleware } from 'openapi-fetch'; 3 | import { ApiError, ErrorInfo } from './types'; 4 | 5 | function recordError(url: string, status: number, error: ApiError | undefined) { 6 | if (error) { 7 | const errorInfo: ErrorInfo = { 8 | url, 9 | status, 10 | message: error.message, 11 | }; 12 | apiClient.POST('/marketing/v1/action', { 13 | body: { 14 | action: 'error', 15 | additional_data: JSON.stringify(errorInfo), 16 | }, 17 | }); 18 | } 19 | } 20 | 21 | const errorMiddleware: Middleware = { 22 | async onResponse({ response }) { 23 | const data = await response.clone(); 24 | if (!response.ok) { 25 | const error = await data.json(); 26 | recordError(response.url, response.status, await error); 27 | return undefined; 28 | } 29 | return response; 30 | }, 31 | }; 32 | 33 | export const apiClient = createClient(import.meta.env.VITE_API_URL); 34 | apiClient.use(errorMiddleware); 35 | -------------------------------------------------------------------------------- /src/views/timetable/event/TimetableEventView.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginVue from 'eslint-plugin-vue'; 3 | import vueTsEslintConfig from '@vue/eslint-config-typescript'; 4 | import eslint from '@eslint/js'; 5 | import tseslint from 'typescript-eslint'; 6 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 7 | 8 | export default [ 9 | ...tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended), 10 | ...pluginVue.configs['flat/recommended'], 11 | ...vueTsEslintConfig(), 12 | eslintPluginPrettierRecommended, 13 | { 14 | languageOptions: { 15 | globals: { 16 | ...globals.node, 17 | }, 18 | 19 | ecmaVersion: 2020, 20 | sourceType: 'module', 21 | }, 22 | 23 | rules: { 24 | 'no-duplicate-imports': 'error', 25 | 26 | 'vue/html-self-closing': [ 27 | 'error', 28 | { 29 | html: { 30 | void: 'always', 31 | normal: 'always', 32 | component: 'always', 33 | }, 34 | 35 | svg: 'always', 36 | math: 'always', 37 | }, 38 | ], 39 | }, 40 | }, 41 | ]; 42 | -------------------------------------------------------------------------------- /src/views/error/ErrorLayout.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 51 | -------------------------------------------------------------------------------- /src/views/admin/users/AdminUsersView.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | Access restricted 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/views/profile/achievement/AchievementsElement.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | {{ props.name }} 12 | 13 | {{ props.description }} 14 | 15 | 16 | 17 | 18 | 38 | -------------------------------------------------------------------------------- /src/store/toolbar.ts: -------------------------------------------------------------------------------- 1 | import { ToolbarActionItem, ToolbarMenuItem } from '@/components/IrdomToolbar.vue'; 2 | import { defineStore } from 'pinia'; 3 | import { ref } from 'vue'; 4 | 5 | interface Setup { 6 | menuItems: ToolbarMenuItem[]; 7 | title: string; 8 | backUrl: string | undefined; 9 | actions: ToolbarActionItem[]; 10 | backable: boolean; 11 | share: boolean; 12 | } 13 | 14 | export const useToolbar = defineStore('toolbar', () => { 15 | const menuItems = ref([]); 16 | const title = ref('Твой ФФ!'); 17 | const backUrl = ref(undefined); 18 | const actions = ref([]); 19 | const backable = ref(false); 20 | const share = ref(false); 21 | 22 | function setup(s: Partial) { 23 | menuItems.value = s.menuItems ?? []; 24 | actions.value = s.actions ?? []; 25 | title.value = s.title ?? 'Твой ФФ!'; 26 | backUrl.value = s.backUrl ?? undefined; 27 | backable.value = s.backable ?? Boolean(s.backUrl); 28 | share.value = s.share ?? false; 29 | } 30 | 31 | return { menuItems, title, backUrl, actions, backable, share, setup }; 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/ForgotPassordForm.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | 33 | Восстановить пароль 34 | 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/pr_create.yml: -------------------------------------------------------------------------------- 1 | name: Тесты и раскатка на Pull Request 2 | 3 | on: pull_request 4 | 5 | env: 6 | REGISTRY: ghcr.io 7 | IMAGE_NAME: ${{ github.repository }} 8 | 9 | jobs: 10 | test: 11 | name: Проверяем стили 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - uses: pnpm/action-setup@v4 18 | with: 19 | version: 9 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | cache: 'pnpm' 25 | 26 | - name: Install 27 | run: pnpm install 28 | 29 | - name: Check 30 | run: pnpm run check 31 | 32 | test-format: 33 | name: Выполняем тесты 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v4 38 | 39 | - uses: pnpm/action-setup@v4 40 | with: 41 | version: 9 42 | 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version: 22 46 | cache: 'pnpm' 47 | 48 | - name: Install 49 | run: pnpm install 50 | 51 | - name: Check 52 | run: pnpm test 53 | -------------------------------------------------------------------------------- /src/components/IrdomToast.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | {{ props.toast.title }} 30 | {{ props.toast.description }} 31 | 32 | 40 | 41 | 42 | 43 | 58 | -------------------------------------------------------------------------------- /src/views/admin/UsersTable.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | id 20 | email 21 | 22 | 23 | 24 | 25 | 26 | 27 | {{ id }} 28 | 29 | {{ email }} 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 54 | -------------------------------------------------------------------------------- /src/views/admin/ScopesTable.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | id 20 | name 21 | comment 22 | 23 | 24 | 25 | 26 | 27 | {{ id }} 28 | {{ name }} 29 | {{ comment }} 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 54 | -------------------------------------------------------------------------------- /src/components/MarkdownRenderer.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 65 | -------------------------------------------------------------------------------- /src/views/timetable/AsyncEventsList.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | Свободный день! 26 | 27 | 28 | 40 | -------------------------------------------------------------------------------- /src/router/admin.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorage, LocalStorageItem } from './../models/LocalStorage'; 2 | import { NavigationGuard, RouteRecordRaw } from 'vue-router'; 3 | 4 | export const adminRoutes: RouteRecordRaw[] = [ 5 | { 6 | path: '', 7 | component: () => import('../views/admin/AdminView.vue'), 8 | }, 9 | { 10 | path: 'groups', 11 | component: () => import('../views/admin/groups/AdminGroupsView.vue'), 12 | }, 13 | { 14 | path: 'scopes', 15 | component: () => import('../views/admin/scopes/AdminScopesView.vue'), 16 | }, 17 | { 18 | path: 'group/:id(\\d+)', 19 | component: () => import('../views/admin/group/AdminGroupView.vue'), 20 | }, 21 | { 22 | path: 'users', 23 | component: () => import('../views/admin/users/AdminUsersView.vue'), 24 | }, 25 | { 26 | path: 'achievement', 27 | component: () => import('../views/admin/achievement/AchievementListView.vue'), 28 | }, 29 | { 30 | path: 'achievement/:id(\\d+)', 31 | component: () => import('../views/admin/achievement/AchievementRecieversView.vue'), 32 | }, 33 | ]; 34 | 35 | export const adminHandler: NavigationGuard = to => { 36 | const isAdmin = to.path.startsWith('/admin'); 37 | const token = LocalStorage.get(LocalStorageItem.Token); 38 | 39 | if (isAdmin && !token) { 40 | return { path: '/auth' }; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/assets/logo/Mobile.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/timetable/init/GroupsListItem.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | {{ `${course} курс` }} 25 | 26 | 27 | {{ group.number }} 28 | 29 | 30 | 31 | 32 | 33 | 55 | -------------------------------------------------------------------------------- /src/views/timetable/room/AsyncRoomInfo.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | {{ room?.name }} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 53 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | Твой ФФ! 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/constants/authButtons.ts: -------------------------------------------------------------------------------- 1 | import logos from '@/assets/logos.svg'; 2 | import { AuthButton } from '@/components/IrdomAuthButton.vue'; 3 | 4 | export const authButtons: AuthButton[] = [ 5 | { 6 | name: 'ЛК МГУ', 7 | link: 'lk-msu', 8 | method: 'lkmsu_auth', 9 | icon: `${logos}#msu`, 10 | color: '#58b4470d', 11 | }, 12 | { 13 | name: '@physics.msu.ru', 14 | link: 'physics-msu', 15 | method: 'physics_auth', 16 | icon: `${logos}#ff`, 17 | color: '#00014c0d', 18 | }, 19 | { 20 | name: '@my.msu.ru', 21 | link: 'my-msu', 22 | method: 'my_msu_auth', 23 | icon: `${logos}#msu`, 24 | color: '#2f39500d', 25 | }, 26 | { 27 | name: 'Yandex', 28 | link: 'yandex', 29 | method: 'yandex_auth', 30 | icon: `${logos}#yandex`, 31 | color: '#e94c000d', 32 | }, 33 | { 34 | name: 'ВК', 35 | link: 'vk', 36 | method: 'vk_auth', 37 | icon: `${logos}#vk`, 38 | color: '#0077ff0d', 39 | }, 40 | { 41 | name: 'Github', 42 | link: 'github', 43 | method: 'github_auth', 44 | icon: `${logos}#github`, 45 | color: '#24292f0d', 46 | }, 47 | { 48 | name: 'Google', 49 | link: 'google', 50 | method: 'google_auth', 51 | icon: `${logos}#google`, 52 | color: '#58b4470d', 53 | }, 54 | { 55 | name: 'Profcomff ID', 56 | link: 'authentic', 57 | method: 'authentic_auth', 58 | icon: `${logos}#pkffid`, 59 | color: '#f8ac0e0d', 60 | }, 61 | ]; 62 | -------------------------------------------------------------------------------- /src/views/timetable/lecturer/TimetableLecturerView.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Сегодня, {{ new Date().toLocaleDateString('ru-RU', { day: '2-digit', month: 'long' }) }} 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 48 | -------------------------------------------------------------------------------- /src/components/RegistrationForm.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 33 | 34 | 41 | 48 | Зарегистрироваться 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/components/EventRow.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 43 | {{ startTs }} 44 | {{ endTs }} 45 | 46 | 47 | 48 | 53 | -------------------------------------------------------------------------------- /src/views/auth/ResetEmail.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 40 | 41 | 42 | Внимание! 43 | Нажимая эту кнопку вы подтверждаете смену Email адреса 44 | Сменить Email 45 | 46 | 47 | 48 | 49 | 55 | -------------------------------------------------------------------------------- /src/router/timetable.ts: -------------------------------------------------------------------------------- 1 | import { StudyGroup } from '@/models'; 2 | import { NavigationGuard, RouteRecordRaw } from 'vue-router'; 3 | import TimetableView from '@/views/timetable/TimetableView.vue'; 4 | import { stringifyDate } from '@/utils/date'; 5 | import { LocalStorage, LocalStorageItem } from '@/models/LocalStorage'; 6 | 7 | export const timetableRoutes: RouteRecordRaw[] = [ 8 | { 9 | path: '', 10 | redirect: `/timetable/${stringifyDate(new Date())}`, 11 | }, 12 | { 13 | path: ':date(\\d{4}-\\d{2}-\\d{2})', 14 | component: TimetableView, 15 | }, 16 | { 17 | path: 'event/:id(\\d+)', 18 | component: () => import('../views/timetable/event/TimetableEventView.vue'), 19 | }, 20 | { 21 | path: 'lecturer/:id(\\d+)', 22 | component: () => import('../views/timetable/lecturer/TimetableLecturerView.vue'), 23 | }, 24 | { 25 | path: 'room/:id(\\d+)', 26 | component: () => import('../views/timetable/room/TimetableRoomView.vue'), 27 | }, 28 | { 29 | path: 'init', 30 | component: () => import('../views/timetable/init/TimetableInitView.vue'), 31 | }, 32 | ]; 33 | 34 | export const timetableHandler: NavigationGuard = to => { 35 | const group = LocalStorage.getObject(LocalStorageItem.StudyGroup); 36 | const isTimetableInit = to.path === '/timetable/init'; 37 | const isTimetable = to.path.match('[0-9]{4}-[0-9]{2}-[0-9]{2}'); 38 | 39 | if (isTimetableInit && group) { 40 | return { path: '/timetable' }; 41 | } 42 | 43 | if (isTimetable && !group) { 44 | return { path: '/timetable/init' }; 45 | } 46 | 47 | return true; 48 | }; 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Профком студентов физфака МГУ 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/store/apps.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | import { Category, AppToken } from '@/models'; 4 | import { LocalStorage, LocalStorageItem } from '@/models/LocalStorage'; 5 | 6 | export const useAppsStore = defineStore('apps', () => { 7 | const categories = ref([]); 8 | const appTokens = ref([]); 9 | 10 | const addAppToken = (appId: number, token: string | undefined, expire: number) => { 11 | appTokens.value.push({ 12 | appId, 13 | token, 14 | expire, 15 | }); 16 | LocalStorage.set(LocalStorageItem.AppToken, appTokens.value); 17 | }; 18 | 19 | const checkAppToken = (appId: number) => { 20 | const appToken = appTokens.value.find(item => item.appId === appId); 21 | const currentDate = new Date(); 22 | 23 | if (!appToken) { 24 | return undefined; 25 | } else { 26 | if (appToken.expire < currentDate.getTime()) { 27 | appTokens.value.splice(appTokens.value.indexOf(appToken), 1); 28 | LocalStorage.set(LocalStorageItem.AppToken, appTokens.value); 29 | return undefined; 30 | } else { 31 | return appToken.token; 32 | } 33 | } 34 | }; 35 | 36 | const getTokensFromStorage = () => { 37 | appTokens.value = LocalStorage.getObject(LocalStorageItem.AppToken) ?? []; 38 | }; 39 | 40 | const clearTokensFromStorage = () => { 41 | LocalStorage.delete(LocalStorageItem.AppToken); 42 | }; 43 | 44 | return { 45 | categories, 46 | appTokens, 47 | 48 | addAppToken, 49 | checkAppToken, 50 | getTokensFromStorage, 51 | clearTokensFromStorage, 52 | }; 53 | }); 54 | -------------------------------------------------------------------------------- /src/views/profile/ProfileDeleteView.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 50 | 51 | 52 | Вы точно хотите удалить аккаунт? Это необратимо. 53 | 54 | Удалить аккаунт 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/router/profile.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '@/api'; 2 | import { RouteRecordRaw } from 'vue-router'; 3 | 4 | export const profileRoutes: RouteRecordRaw[] = [ 5 | { 6 | path: '', 7 | component: () => import('@/views/profile/ProfileView.vue'), 8 | }, 9 | { 10 | path: 'sessions', 11 | component: () => import('@/views/profile/sessions/ProfileSessionsView.vue'), 12 | }, 13 | { 14 | path: ':id(\\d+)', 15 | component: () => import('@/views/profile/ProfileView.vue'), 16 | beforeEnter: async to => { 17 | const { response } = await apiClient.GET('/auth/user/{user_id}', { 18 | params: { path: { user_id: Number(to.params.id) } }, 19 | }); 20 | if (response.ok) { 21 | return true; 22 | } else { 23 | return { path: '/error' }; 24 | } 25 | }, 26 | }, 27 | // Раскомментируйте, если починили; удалите, если решили дропнуть 28 | // { 29 | // path: 'edit', 30 | // component: () => import('@/views/profile/ProfileEditView.vue'), 31 | // }, 32 | { 33 | path: 'edit-auth', 34 | component: () => import('@/views/profile/ProfileEditAuthView.vue'), 35 | }, 36 | { 37 | path: 'settings', 38 | component: () => import('@/views/profile/ProfileSettingsView.vue'), 39 | }, 40 | { 41 | path: 'change-password', 42 | component: () => import('@/views/auth/ChangePasswordView.vue'), 43 | }, 44 | { 45 | path: 'change-email', 46 | component: () => import('@/views/auth/ChangeEmailView.vue'), 47 | }, 48 | { 49 | path: 'add-email', 50 | component: () => import('@/views/auth/AddEmailView.vue'), 51 | }, 52 | { 53 | path: 'delete-account', 54 | component: () => import('@/views/profile/ProfileDeleteView.vue'), 55 | }, 56 | ]; 57 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 45 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/views/profile/achievement/AchievementsSlider.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | У тебя еще нет достижений в приложении :( 40 | 41 | 42 | 43 | 62 | -------------------------------------------------------------------------------- /src/views/timetable/lecturer/AsyncLecturerInfo.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | {{ fullName }} 38 | 39 | 40 | 41 | 63 | -------------------------------------------------------------------------------- /webapp-ui.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".", 5 | }, 6 | ], 7 | "extensions": { 8 | "recommendations": [ 9 | "Vue.volar", 10 | "Vue.vscode-typescript-vue-plugin", 11 | "dbaeumer.vscode-eslint", 12 | "esbenp.prettier-vscode", 13 | "formulahendry.auto-close-tag", 14 | "formulahendry.auto-rename-tag", 15 | "steoates.autoimport", 16 | "vunguyentuan.vscode-css-variables", 17 | ], 18 | }, 19 | "tasks": { 20 | "version": "2.0.0", 21 | "tasks": [ 22 | { 23 | "label": "Configure", 24 | "type": "shell", 25 | "command": "pnpm install", 26 | }, 27 | { 28 | "label": "Run local", 29 | "type": "shell", 30 | "command": "pnpm dev", 31 | }, 32 | { 33 | "label": "Build", 34 | "type": "shell", 35 | "command": "pnpm build", 36 | "group": { 37 | "kind": "build", 38 | "isDefault": true, 39 | }, 40 | }, 41 | { 42 | "label": "Test", 43 | "type": "shell", 44 | "command": "pnpm test", 45 | "group": { 46 | "kind": "test", 47 | "isDefault": true, 48 | }, 49 | }, 50 | ], 51 | }, 52 | "launch": { 53 | "version": "0.2.0", 54 | "configurations": [ 55 | { 56 | "name": "Launch dev", 57 | "request": "launch", 58 | "runtimeArgs": ["dev"], 59 | "runtimeExecutable": "pnpm", 60 | "skipFiles": ["/**"], 61 | "type": "node", 62 | "serverReadyAction": { 63 | "pattern": "Local: http://localhost:([0-9]+)/", 64 | "uriFormat": "http://localhost:%s", 65 | "action": "openExternally", 66 | }, 67 | }, 68 | ], 69 | }, 70 | "settings": { 71 | "typescript.tsdk": "node_modules/typescript/lib", 72 | "css.validate": false, 73 | "[vue]": { 74 | "editor.defaultFormatter": "esbenp.prettier-vscode", 75 | }, 76 | }, 77 | } 78 | -------------------------------------------------------------------------------- /src/views/admin/achievement/AchievementRow.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | 30 | {{ props.achievement.id }} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 48 | 49 | 50 | 56 | 57 | 58 | 59 | 60 | 65 | -------------------------------------------------------------------------------- /src/utils/date.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { getWeekdayName, getDateWithDayOffset } from './date'; 3 | 4 | describe('Convert date functions:', () => { 5 | it('should return correct weekday names', () => { 6 | expect(getWeekdayName(new Date(2023, 2, 13)).toLowerCase()).toBe('пн'); 7 | expect(getWeekdayName(new Date(2023, 2, 14)).toLowerCase()).toBe('вт'); 8 | expect(getWeekdayName(new Date(2023, 2, 15)).toLowerCase()).toBe('ср'); 9 | expect(getWeekdayName(new Date(2023, 2, 16)).toLowerCase()).toBe('чт'); 10 | expect(getWeekdayName(new Date(2023, 2, 17)).toLowerCase()).toBe('пт'); 11 | expect(getWeekdayName(new Date(2023, 2, 18)).toLowerCase()).toBe('сб'); 12 | expect(getWeekdayName(new Date(2023, 2, 19)).toLowerCase()).toBe('вс'); 13 | }); 14 | 15 | it('should correctly change date by offset', () => { 16 | const date = new Date(2023, 2, 13); 17 | expect(getDateWithDayOffset(date, 0)).toEqual(date); 18 | 19 | expect(getWeekdayName(getDateWithDayOffset(date, 0))).toBe('пн'); 20 | expect(getWeekdayName(getDateWithDayOffset(date, 1))).toBe('вт'); 21 | expect(getWeekdayName(getDateWithDayOffset(date, 2))).toBe('ср'); 22 | expect(getWeekdayName(getDateWithDayOffset(date, 3))).toBe('чт'); 23 | expect(getWeekdayName(getDateWithDayOffset(date, 4))).toBe('пт'); 24 | expect(getWeekdayName(getDateWithDayOffset(date, 5))).toBe('сб'); 25 | expect(getWeekdayName(getDateWithDayOffset(date, 6))).toBe('вс'); 26 | expect(getWeekdayName(getDateWithDayOffset(date, -1))).toBe('вс'); 27 | expect(getWeekdayName(getDateWithDayOffset(date, -2))).toBe('сб'); 28 | expect(getWeekdayName(getDateWithDayOffset(date, -3))).toBe('пт'); 29 | expect(getWeekdayName(getDateWithDayOffset(date, -4))).toBe('чт'); 30 | expect(getWeekdayName(getDateWithDayOffset(date, -5))).toBe('ср'); 31 | expect(getWeekdayName(getDateWithDayOffset(date, -6))).toBe('вт'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/views/timetable/init/AsyncGroupsList.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 | 50 | 51 | 60 | 61 | 62 | 63 | 64 | 75 | -------------------------------------------------------------------------------- /src/utils/UserdataConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { UserdataConverter } from './UserdataConverter'; 3 | import { 4 | UserdataParamResponseType, 5 | UserdataRaw, 6 | UserdataArrayItem, 7 | UserdataTree, 8 | UserdataTreeSheet, 9 | } from '@/models'; 10 | 11 | const flatResposne: UserdataRaw = { 12 | items: [ 13 | { 14 | category: 'Address', 15 | param: 'street', 16 | value: 'Моховая', 17 | }, 18 | { 19 | category: 'Address', 20 | param: 'city', 21 | value: 'Москва', 22 | }, 23 | ], 24 | }; 25 | 26 | const treeSheet: UserdataTreeSheet = new Map([ 27 | [ 28 | 'street', 29 | { 30 | name: 'Моховая', 31 | is_required: false, 32 | changeable: true, 33 | type: UserdataParamResponseType.All, 34 | }, 35 | ], 36 | [ 37 | 'city', 38 | { 39 | name: 'Москва', 40 | is_required: false, 41 | changeable: true, 42 | type: UserdataParamResponseType.All, 43 | }, 44 | ], 45 | ]); 46 | 47 | const expectedObject: UserdataTree = new Map([['Address', treeSheet]]); 48 | 49 | const arrayItem: UserdataArrayItem = { 50 | name: 'Address', 51 | data: [ 52 | { 53 | param: 'street', 54 | value: { 55 | name: 'Моховая', 56 | is_required: false, 57 | changeable: true, 58 | type: UserdataParamResponseType.All, 59 | }, 60 | }, 61 | { 62 | param: 'city', 63 | value: { 64 | name: 'Москва', 65 | is_required: false, 66 | changeable: true, 67 | type: UserdataParamResponseType.All, 68 | }, 69 | }, 70 | ], 71 | }; 72 | 73 | describe('Userdata converter:', () => { 74 | it('should convert a flat api response to a deep object', () => { 75 | expect(UserdataConverter.flatToTree(flatResposne)).toEqual(expectedObject); 76 | }); 77 | 78 | it('should convert a tree sheet to an array item', () => { 79 | expect(UserdataConverter.sheetToItem('Address', treeSheet)).toEqual(arrayItem); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/views/timetable/DateNavigation.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 20 | 21 | {{ getWeekdayName(yestarday) }} 22 | 23 | 24 | {{ getWeekdayName(date, 'long') }} 25 | 26 | 32 | {{ getWeekdayName(tomorrow) }} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 84 | -------------------------------------------------------------------------------- /src/views/profile/ProfileEditAuthView.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | 32 | 33 | Привязать аккаунт 34 | 35 | 41 | 42 | 43 | 44 | 45 | Отвязать аккаунт 46 | 47 | 55 | 56 | 57 | 58 | 59 | 60 | 75 | -------------------------------------------------------------------------------- /src/components/DataRow.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{ title }} 20 | 21 | {{ title }} 22 | 23 | 24 | {{ info }} 25 | 26 | 27 | 28 | 29 | 105 | -------------------------------------------------------------------------------- /src/assets/logo/Android.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/vuetify.ts: -------------------------------------------------------------------------------- 1 | import { createVuetify } from 'vuetify'; 2 | import { aliases, md } from 'vuetify/iconsets/md'; 3 | 4 | const profcomffLight = { 5 | dark: false, 6 | colors: { 7 | primary: 'rgb(0, 1, 76)', 8 | 'on-primary': 'rgb(255, 255, 255)', 9 | 10 | secondary: 'rgb(255, 139, 0)', 11 | 'on-secondary': 'rgb(255, 255, 255)', 12 | 13 | background: 'rgb(255, 255, 255)', 14 | 'on-background': 'rgb(0, 0, 0)', 15 | 16 | surface: 'rgb(245, 245, 245)', 17 | 'on-surface': 'rgb(0, 0, 0, 0.87)', 18 | 19 | 'surface-variant': 'rgb(245, 245, 245)', 20 | 'on-surface-variant': 'rgba(0, 0, 0, 0.87)', 21 | }, 22 | }; 23 | 24 | const datePicker = { 25 | dark: false, 26 | colors: { 27 | primary: 'rgb(0, 1, 76)', 28 | 'on-primary': 'rgb(255, 255, 255)', 29 | 30 | secondary: 'rgb(255, 139, 0)', 31 | 'on-secondary': 'rgb(0, 0, 0)', 32 | 33 | surface: 'rgb(0, 1, 76)', 34 | 'on-surface': 'rgb(255, 255, 255)', 35 | 36 | 'surface-variant': 'rgb(255, 139, 0)', 37 | 'on-surface-variant': 'rgb(0, 0, 0)', 38 | }, 39 | }; 40 | 41 | export const vuetify = createVuetify({ 42 | icons: { 43 | defaultSet: 'md', 44 | aliases, 45 | sets: { 46 | md, 47 | }, 48 | }, 49 | defaults: { 50 | VChip: { 51 | style: 'border-radius: 999px !important;', 52 | }, 53 | VContainer: { 54 | style: 'max-width: 900px;', 55 | }, 56 | VBottomNavigation: { 57 | style: 58 | 'background-color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-on-primary));', 59 | }, 60 | VAppBar: { 61 | style: 62 | 'background-color: rgb(var(--v-theme-primary)); color: rgb(var(--v-theme-on-primary));', 63 | }, 64 | VList: { 65 | style: 66 | 'background-color: rgb(var(--v-theme-background)); color: rgb(var(--v-theme-on-background))', 67 | }, 68 | VCard: { 69 | style: 70 | 'background-color: rgb(var(--v-theme-surface)); color: rgba(var(--v-theme-on-surface))', 71 | }, 72 | VSheet: { 73 | style: 'background-color: white;', 74 | }, 75 | }, 76 | theme: { 77 | defaultTheme: 'profcomffLight', 78 | themes: { 79 | profcomffLight, 80 | datePicker, 81 | }, 82 | }, 83 | locale: { 84 | locale: 'ru', 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /src/views/timetable/TimetableView.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/assets/unexpected_error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tvoi-ff", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "build": "vite build", 9 | "build:development": "vite build --mode development", 10 | "build:testing": "vite build --mode testing", 11 | "preview": "vite preview --port 443", 12 | "test": "vitest", 13 | "lint": "eslint \"**/*.{vue,ts}\"", 14 | "lint:fix": "eslint \"**/*.{vue,ts}\" --fix", 15 | "lint:deadcode": "knip --exclude binaries,dependencies,unlisted", 16 | "lint:circular": "dpdm --exit-code circular:1 --no-tree --no-warning --progress false --transform ./src/main.ts", 17 | "prettier": "prettier . --check", 18 | "prettier:write": "prettier . --write", 19 | "stylelint": "stylelint \"**/*.{vue,css}\"", 20 | "stylelint:fix": "stylelint \"**/*.{vue,css}\" --fix", 21 | "format": "vue-tsc && pnpm run lint:fix && pnpm run prettier:write && pnpm run stylelint:fix", 22 | "check": "vue-tsc && pnpm run lint && pnpm run prettier && pnpm run stylelint" 23 | }, 24 | "dependencies": { 25 | "@profcomff/api-uilib": "^2024.11.24", 26 | "markdown-it": "^14.1.0", 27 | "openapi-fetch": "^0.13.0", 28 | "pinia": "^2.2.6", 29 | "ua-parser-js": "^1.0.39", 30 | "vue": "^3.5.13", 31 | "vue-router": "^4.4.5", 32 | "workbox-window": "^7.3.0" 33 | }, 34 | "devDependencies": { 35 | "@eslint/js": "^9.15.0", 36 | "@profcomff/api-uilib": "^2024.9.29", 37 | "@types/markdown-it": "^14.1.2", 38 | "@types/node": "^22.9.3", 39 | "@types/ua-parser-js": "^0.7.39", 40 | "@vitejs/plugin-vue": "^5.2.0", 41 | "@vue/eslint-config-typescript": "^14.1.3", 42 | "dpdm": "^3.14.0", 43 | "eslint": "^9.15.0", 44 | "eslint-config-prettier": "^9.1.0", 45 | "eslint-plugin-prettier": "^5.2.1", 46 | "eslint-plugin-vue": "^9.31.0", 47 | "globals": "^15.12.0", 48 | "knip": "^5.45.0", 49 | "postcss": "^8.4.49", 50 | "postcss-html": "^1.7.0", 51 | "postcss-preset-env": "^10.1.1", 52 | "prettier": "^3.3.3", 53 | "stylelint": "^16.10.0", 54 | "stylelint-config-recommended-vue": "^1.5.0", 55 | "stylelint-config-standard": "^36.0.1", 56 | "typescript": "5.6.3", 57 | "typescript-eslint": "^8.26.0", 58 | "vite": "^5.4.11", 59 | "vite-plugin-pwa": "^0.21.0", 60 | "vite-plugin-vuetify": "^2.0.4", 61 | "vitest": "^2.1.5", 62 | "vue-tsc": "^2.1.10", 63 | "vuetify": "^3.7.4" 64 | }, 65 | "overrides": { 66 | "workbox-build": "^7.3.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/assets/forbidden.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/views/auth/ChangeEmailView.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 55 | 56 | 60 | 61 | Текущий адрес электронной почты: {{ current_email }} 62 | 63 | 71 | Изменить Email 72 | 73 | 74 | 75 | 76 | 90 | -------------------------------------------------------------------------------- /src/views/timetable/init/TimetableInitView.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 33 | 34 | 35 | 36 | Добро пожаловать! 37 | Наше приложение позволит получить доступ к сервисам для студентов ФФ МГУ! 38 | Для просмотра расписания выберите свою группу: 39 | 40 | 41 | 42 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 94 | -------------------------------------------------------------------------------- /src/views/auth/ChangePasswordView.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 | 44 | 48 | 55 | 56 | 64 | 65 | 73 | Изменить пароль 74 | 75 | 76 | 77 | 78 | 94 | -------------------------------------------------------------------------------- /src/views/auth/OauthRegisterView.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 48 | 49 | В первый раз в приложении? 50 | У вас еще нет аккаунта на этот логин или метод входа. 51 | 52 | Если у вас уже есть аккаунт Твой ФФ – нажмите Нет и выберите другой метод входа. 53 | 54 | 55 | Если вы впервые входите в Твой ФФ – нажмите Да. Тогда мы создадим для вас новый 56 | профиль! 57 | 58 | 59 | Создать новый профиль? 60 | 61 | Нет 62 | Да 63 | 64 | 65 | 66 | 67 | 88 | -------------------------------------------------------------------------------- /src/components/IrdomAuthButton.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 53 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | {{ button.name }} 68 | 69 | 70 | 71 | 72 | 73 | Вы точно хотите отвязать аккаунт? 74 | 75 | 76 | Не отвязывать 77 | Отвязать 78 | 79 | 80 | 81 | 82 | 83 | 98 | -------------------------------------------------------------------------------- /src/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/views/admin/scopes/AdminScopesView.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 | 61 | 62 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | Access restricted 78 | 79 | 80 | 81 | 82 | 83 | 92 | -------------------------------------------------------------------------------- /src/views/timetable/room/TimetableRoomView.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | Карта этажа 39 | 40 | 41 | 42 | Посмотреть на карте 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Сегодня, {{ new Date().toLocaleDateString('ru-RU', { day: '2-digit', month: 'long' }) }} 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 117 | -------------------------------------------------------------------------------- /src/views/profile/ProfileSettingsView.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 37 | 38 | 39 | 45 | Методы входа 46 | 47 | 54 | Изменение почты 55 | 56 | 63 | Добавление почты 64 | 65 | 72 | Изменение пароля 73 | 74 | 80 | Текущие сессии 81 | 82 | 89 | Удалить аккаунт 90 | 91 | Выход 92 | 93 | 94 | 95 | 96 | 115 | -------------------------------------------------------------------------------- /src/views/auth/AddEmailView.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 54 | 55 | 59 | 67 | 75 | 83 | Добавить Email 84 | 85 | 86 | 87 | 88 | 102 | -------------------------------------------------------------------------------- /src/api/controllers/auth/decorators.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { ApiError } from '@/api'; 3 | import { apiClient } from '../../client'; 4 | import { ToastType } from '@/models'; 5 | import router from '@/router'; 6 | import { useProfileStore } from '@/store/profile'; 7 | import { useToastStore } from '@/store/toast'; 8 | 9 | type Func = (...args: FuncArgs) => R; 10 | type Decorator = Func< 11 | F, 12 | [F, ...DecoratorArgs] 13 | >; 14 | type DecoratorTuple = [ 15 | D, 16 | ...(D extends Decorator ? DA : never), 17 | ]; 18 | 19 | export function scoped( 20 | method: F, 21 | scope: string 22 | ): Func, Parameters> { 23 | return (...args) => { 24 | const { hasTokenAccess } = useProfileStore(); 25 | const toastStore = useToastStore(); 26 | 27 | if (hasTokenAccess(scope)) { 28 | return method(...args); 29 | } 30 | 31 | toastStore.push({ 32 | title: `У вас нет доступа к методу ${method.name}`, 33 | type: ToastType.Error, 34 | }); 35 | }; 36 | } 37 | 38 | export function showErrorToast( 39 | method: F 40 | ): Func>, Parameters> { 41 | return async (...args: any[]) => { 42 | const toastStore = useToastStore(); 43 | try { 44 | const response = await method(...args); 45 | return response; 46 | } catch (err) { 47 | const error = err as ApiError; 48 | if (error) { 49 | toastStore.push({ 50 | title: error.ru ?? error.message, 51 | type: ToastType.Error, 52 | }); 53 | } else { 54 | toastStore.push({ 55 | title: 'Неизвестная ошибка', 56 | description: '', 57 | type: ToastType.Error, 58 | }); 59 | } 60 | } 61 | }; 62 | } 63 | 64 | export function checkToken>( 65 | method: F 66 | ): Func>, Parameters> { 67 | return async (...args: any[]) => { 68 | const { response: checkResponse } = await apiClient.GET('/auth/me'); 69 | if (!checkResponse.ok && checkResponse.status === 403) { 70 | const { deleteToken } = useProfileStore(); 71 | const toastStore = useToastStore(); 72 | deleteToken(); 73 | router.push('/auth'); 74 | toastStore.push({ title: 'Сессия истекла' }); 75 | } 76 | 77 | const response = await method(...args); 78 | return response; 79 | }; 80 | } 81 | 82 | export function apply( 83 | method: F, 84 | ...decoratorTuples: DecoratorTuple[] 85 | ): Func, Parameters> { 86 | if (decoratorTuples.length) { 87 | const decoratorTuple = decoratorTuples.shift()!; 88 | const decorator = decoratorTuple[0]; 89 | const args = decoratorTuple.slice(1) as unknown[]; 90 | return apply(decorator(method, ...args), ...decoratorTuples); 91 | } 92 | return method; 93 | } 94 | -------------------------------------------------------------------------------- /src/components/IrdomToolbar.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {{ title }} 73 | 74 | 75 | 82 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | {{ name }} 96 | 97 | 98 | 99 | 100 | 101 | 102 | 107 | -------------------------------------------------------------------------------- /src/views/admin/groups/AdminGroupsView.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 115 | -------------------------------------------------------------------------------- /src/assets/not_found.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/store/timetable.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorage, LocalStorageItem } from '@/models/LocalStorage'; 2 | import { stringifyDate } from '@/utils/date'; 3 | import { defineStore } from 'pinia'; 4 | import { ref } from 'vue'; 5 | import { Lecturer, Room, Event, StudyGroup } from '@/models'; 6 | 7 | interface StoreLecturer extends Lecturer { 8 | schedule: Map | null; 9 | } 10 | 11 | interface StoreRoom extends Room { 12 | schedule: Map | null; 13 | } 14 | 15 | export const useTimetableStore = defineStore('timetable', () => { 16 | const events = ref>(new Map()); 17 | const days = ref>(new Map()); 18 | const lecturers = ref>(new Map()); 19 | const rooms = ref>(new Map()); 20 | const group = ref(null); 21 | 22 | function updateGroup(newGroup?: StudyGroup) { 23 | group.value = newGroup ?? LocalStorage.getObject(LocalStorageItem.StudyGroup); 24 | } 25 | 26 | function setLecturers(lecturerList: Lecturer[]) { 27 | for (const l of lecturerList) { 28 | if (!lecturers.value.has(l.id)) { 29 | (l as StoreLecturer).schedule = null; 30 | lecturers.value.set(l.id, l as StoreLecturer); 31 | } else if (l.avatar_link) { 32 | lecturers.value.get(l.id)!.avatar_link = l.avatar_link; 33 | } 34 | } 35 | } 36 | 37 | function setRooms(roomList: Room[]) { 38 | for (const r of roomList) { 39 | if (!rooms.value.has(r.id)) { 40 | (r as StoreRoom).schedule = null; 41 | rooms.value.set(r.id, r as StoreRoom); 42 | } 43 | } 44 | } 45 | 46 | function setEvents(eventList: Event[]) { 47 | for (const e of eventList) { 48 | events.value.set(e.id, e); 49 | } 50 | } 51 | 52 | function setDay(date: Date, eventList: Event[]) { 53 | const key = stringifyDate(date); 54 | 55 | if (!days.value.has(key)) { 56 | days.value.set(key, []); 57 | } 58 | 59 | for (const e of eventList) { 60 | setEvents(eventList); 61 | days.value.get(key)?.push(e); 62 | } 63 | } 64 | 65 | function setLecturerEvents(lecturerId: number, eventList: Event[]) { 66 | setEvents(eventList); 67 | const lecturer = lecturers.value.get(lecturerId); 68 | 69 | if (!lecturer) return; 70 | 71 | if (lecturer.schedule) { 72 | for (const e of eventList) { 73 | lecturer.schedule.set(e.id, e); 74 | } 75 | } else { 76 | lecturer.schedule = new Map(eventList.map(e => [e.id, e])); 77 | } 78 | } 79 | 80 | function setRoomEvents(roomId: number, eventList: Event[]) { 81 | setEvents(eventList); 82 | const room = rooms.value.get(roomId); 83 | 84 | if (!room) return; 85 | 86 | if (room.schedule) { 87 | for (const e of eventList) { 88 | room.schedule.set(e.id, e); 89 | } 90 | } else { 91 | room.schedule = new Map(eventList.map(e => [e.id, e])); 92 | } 93 | } 94 | 95 | return { 96 | events, 97 | days, 98 | lecturers, 99 | rooms, 100 | group, 101 | updateGroup, 102 | setLecturers, 103 | setRooms, 104 | setEvents, 105 | setDay, 106 | setLecturerEvents, 107 | setRoomEvents, 108 | }; 109 | }); 110 | -------------------------------------------------------------------------------- /src/store/profile.ts: -------------------------------------------------------------------------------- 1 | import { setupAuth } from '@profcomff/api-uilib'; 2 | import { scopename } from './../models/ScopeName'; 3 | import { LocalStorage, LocalStorageItem } from '@/models/LocalStorage'; 4 | import { defineStore } from 'pinia'; 5 | import { computed, ref } from 'vue'; 6 | import { apiClient } from '@/api/'; 7 | 8 | export const useProfileStore = defineStore('profile', () => { 9 | const id = ref(null); 10 | const token = ref(); 11 | const tokenScopes = ref([]); 12 | const marketingId = ref(null); 13 | const authMethods = ref(null); 14 | 15 | const groups = ref(null); 16 | const indirectGroups = ref(null); 17 | const userScopes = ref(null); 18 | const sessionScopes = ref(null); 19 | 20 | function updateToken(newToken?: string) { 21 | token.value = newToken ?? LocalStorage.get(LocalStorageItem.Token) ?? undefined; 22 | token.value = newToken ?? LocalStorage.get(LocalStorageItem.Token) ?? undefined; 23 | setupAuth(token.value); 24 | } 25 | 26 | function hasTokenAccess(scopeName: string) { 27 | return Boolean(tokenScopes.value.includes(scopeName)); 28 | } 29 | 30 | function hasUserAccess(scopeName: string) { 31 | return Boolean(userScopes.value?.includes(scopeName)); 32 | } 33 | 34 | function updateTokenScopes(newTokenScopes?: string[]) { 35 | tokenScopes.value = 36 | newTokenScopes ?? LocalStorage.getObject(LocalStorageItem.TokenScopes) ?? []; 37 | } 38 | 39 | function deleteToken() { 40 | LocalStorage.delete(LocalStorageItem.Token, LocalStorageItem.TokenScopes); 41 | [ 42 | id, 43 | token, 44 | tokenScopes, 45 | authMethods, 46 | groups, 47 | indirectGroups, 48 | userScopes, 49 | sessionScopes, 50 | ].forEach(i => { 51 | i.value = null; 52 | }); 53 | } 54 | 55 | async function updateMarketingId(newMarketingId?: number) { 56 | const item = LocalStorage.get(LocalStorageItem.MarketingId); 57 | if (newMarketingId) { 58 | marketingId.value = newMarketingId; 59 | } else if (item === null) { 60 | const { data } = await apiClient.POST('/marketing/v1/user'); 61 | if (data) { 62 | LocalStorage.set(LocalStorageItem.MarketingId, data.id); 63 | marketingId.value = data.id; 64 | apiClient.POST('/marketing/v1/action', { 65 | body: { 66 | user_id: data.id, 67 | action: 'user registration', 68 | additional_data: JSON.stringify(data), 69 | }, 70 | }); 71 | } 72 | } else { 73 | marketingId.value = +item; 74 | } 75 | } 76 | 77 | const isUserLogged = computed(() => !!token.value); 78 | 79 | const isAdmin = computed(() => tokenScopes.value?.includes(scopename.webapp.admin.show)); 80 | 81 | return { 82 | token, 83 | updateToken, 84 | groups, 85 | indirectGroups, 86 | userScopes, 87 | tokenScopes, 88 | hasTokenAccess, 89 | hasUserAccess, 90 | updateTokenScopes, 91 | isUserLogged, 92 | isAdmin, 93 | updateMarketingId, 94 | marketingId, 95 | id, 96 | authMethods, 97 | sessionScopes, 98 | deleteToken, 99 | }; 100 | }); 101 | -------------------------------------------------------------------------------- /src/views/auth/ResetPassword.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 | 44 | 45 | 46 | 50 | 57 | 64 | Восстановить аккаунт 65 | 66 | 67 | 68 | 69 | При регистрации и входе вы соглашаетесь 70 | 71 | с политикой обработки данных 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 115 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorage, LocalStorageItem } from '@/models/LocalStorage'; 2 | import { adminRoutes, adminHandler } from './admin'; 3 | import { profileRoutes } from './profile'; 4 | import { authHandler, authRoutes } from './auth'; 5 | import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; 6 | import { timetableRoutes, timetableHandler } from './timetable'; 7 | import AppsView from '@/views/apps/AppsView.vue'; 8 | import { useProfileStore } from '@/store/profile'; 9 | import { apiClient } from '@/api/'; 10 | 11 | const routes: RouteRecordRaw[] = [ 12 | { 13 | path: '/', 14 | redirect: '/timetable', 15 | }, 16 | { 17 | path: '/apps', 18 | component: AppsView, 19 | }, 20 | { 21 | path: '/apps/:id(\\d+)', 22 | component: () => import('@/views/apps/ApplicationFrame.vue'), 23 | props: route => ({ 24 | id: Number(route.params.id), 25 | lecturer: route.query.lecturer_id, 26 | relativePath: route.query.relativePath || '', 27 | }), 28 | beforeEnter: route => { 29 | console.log(route.params, route.query); 30 | }, 31 | }, 32 | 33 | { 34 | path: '/apps/44/lecturer', 35 | redirect: to => { 36 | const query = to.query; 37 | const relativePath = to.path.slice(to.path.lastIndexOf('/') + 1); 38 | 39 | return { 40 | path: '/apps/44', 41 | query: { 42 | lecturer_id: query.lecturer_id, 43 | relativePath: relativePath, 44 | }, 45 | }; 46 | }, 47 | }, 48 | { 49 | path: '/timetable', 50 | children: timetableRoutes, 51 | }, 52 | { 53 | path: '/auth', 54 | children: authRoutes, 55 | }, 56 | { 57 | path: '/admin', 58 | children: adminRoutes, 59 | }, 60 | { 61 | path: '/profile', 62 | children: profileRoutes, 63 | }, 64 | { 65 | path: '/forbidden', 66 | component: () => import('@/views/error/Error403View.vue'), 67 | }, 68 | { 69 | path: '/:pathMatch(.*)', 70 | component: () => import('@/views/error/Error404View.vue'), 71 | }, 72 | ]; 73 | 74 | const router = createRouter({ 75 | history: createWebHistory(import.meta.env.BASE_URL), 76 | routes, 77 | }); 78 | 79 | router.beforeEach(async to => { 80 | const token = LocalStorage.get(LocalStorageItem.Token); 81 | const isProfile = to.path.startsWith('/profile'); 82 | const isAuth = to.path.startsWith('/auth'); 83 | 84 | if (isProfile && !token) { 85 | return { path: '/auth' }; 86 | } 87 | // TODO: Убрать костыль с to.path 88 | if ( 89 | isAuth && 90 | token && 91 | to.path !== '/auth/reset/email' && 92 | to.path !== '/auth/error' && 93 | !to.path.startsWith('/auth/oauth-authorized') 94 | ) { 95 | return { path: '/profile' }; 96 | } 97 | }); 98 | router.beforeEach(timetableHandler); 99 | router.beforeEach(adminHandler); 100 | router.beforeEach(authHandler); 101 | 102 | router.afterEach((to, from) => { 103 | const { marketingId } = useProfileStore(); 104 | if (marketingId) { 105 | apiClient.POST('/marketing/v1/action', { 106 | body: { 107 | action: 'route to', 108 | path_from: from.fullPath, 109 | path_to: to.fullPath, 110 | user_id: marketingId, 111 | }, 112 | }); 113 | } 114 | }); 115 | 116 | export default router; 117 | -------------------------------------------------------------------------------- /src/views/timetable/CalendarDropdown.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 42 | 51 | 52 | {{ date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }) }} 53 | 54 | 55 | 56 | {{ `Группа ${group?.number ?? ''}` }} 57 | 58 | 59 | 60 | 61 | 67 | 75 | 76 | 77 | 78 | 134 | -------------------------------------------------------------------------------- /vitePwaManifest.ts: -------------------------------------------------------------------------------- 1 | import { ManifestOptions } from 'vite-plugin-pwa'; 2 | 3 | export const vitePwaManifest: Partial = { 4 | icons: [ 5 | { 6 | src: 'icons/maskable/icon_1024x1024.webp', 7 | sizes: '1024x1024', 8 | type: 'image/webp', 9 | purpose: 'maskable', 10 | }, 11 | { 12 | src: 'icons/maskable/icon_512x512.webp', 13 | sizes: '512x512', 14 | type: 'image/webp', 15 | purpose: 'maskable', 16 | }, 17 | { 18 | src: 'icons/maskable/icon_192x192.webp', 19 | sizes: '192x192', 20 | type: 'image/webp', 21 | purpose: 'maskable', 22 | }, 23 | { 24 | src: 'icons/maskable/icon_144x144.webp', 25 | sizes: '144x144', 26 | type: 'image/webp', 27 | purpose: 'maskable', 28 | }, 29 | { 30 | src: 'icons/maskable/icon_96x96.webp', 31 | sizes: '96x96', 32 | type: 'image/webp', 33 | purpose: 'maskable', 34 | }, 35 | { 36 | src: 'icons/maskable/icon_72x72.webp', 37 | sizes: '72x72', 38 | type: 'image/webp', 39 | purpose: 'maskable', 40 | }, 41 | { 42 | src: 'icons/maskable/icon_48x48.webp', 43 | sizes: '48x48', 44 | type: 'image/webp', 45 | purpose: 'maskable', 46 | }, 47 | { 48 | src: 'icons/maskable/icon_36x36.webp', 49 | sizes: '36x36', 50 | type: 'image/webp', 51 | purpose: 'maskable', 52 | }, 53 | { 54 | src: 'icons/icon_512x512.webp', 55 | sizes: '512x512', 56 | type: 'image/webp', 57 | }, 58 | { 59 | src: 'icons/icon_192x192.webp', 60 | sizes: '192x192', 61 | type: 'image/webp', 62 | }, 63 | { 64 | src: 'icons/icon_144x144.webp', 65 | sizes: '144x144', 66 | type: 'image/webp', 67 | }, 68 | { 69 | src: 'icons/icon_96x96.webp', 70 | sizes: '96x96', 71 | type: 'image/webp', 72 | }, 73 | { 74 | src: 'icons/icon_72x72.webp', 75 | sizes: '72x72', 76 | type: 'image/webp', 77 | }, 78 | { 79 | src: 'icons/icon_48x48.webp', 80 | sizes: '48x48', 81 | type: 'image/webp', 82 | }, 83 | { 84 | src: 'icons/icon_36x36.webp', 85 | sizes: '36x36', 86 | type: 'image/webp', 87 | }, 88 | { 89 | src: 'icons/icon_1024x1024.webp', 90 | sizes: '1024x1024', 91 | type: 'image/webp', 92 | }, 93 | { 94 | src: 'icons/icon_1024x1024.png', 95 | sizes: '1024x1024', 96 | type: 'image/png', 97 | }, 98 | { 99 | src: 'icons/icon_180x180.webp', 100 | sizes: '180x180', 101 | type: 'image/webp', 102 | }, 103 | { 104 | src: 'icons/icon_167x167.webp', 105 | sizes: '167x167', 106 | type: 'image/webp', 107 | }, 108 | { 109 | src: 'icons/icon_152x152.webp', 110 | sizes: '152x152', 111 | type: 'image/webp', 112 | }, 113 | { 114 | src: 'icons/icon_120x120.webp', 115 | sizes: '120x120', 116 | type: 'image/webp', 117 | }, 118 | ], 119 | name: process.env.MODE === 'production' ? 'Твой Физфак!' : `Твой ФФ! (${process.env.MODE})`, 120 | short_name: process.env.MODE === 'production' ? 'Твой ФФ!' : `Твой ФФ! (${process.env.MODE})`, 121 | orientation: 'portrait', 122 | display: 'standalone', 123 | start_url: '/timetable', 124 | description: 'Приложение с сервисами для студентов и сотрудников физического факультета МГУ', 125 | theme_color: '#00004b', 126 | background_color: '#00004b', 127 | lang: 'ru', 128 | scope: process.env.BASE_URL, 129 | }; 130 | -------------------------------------------------------------------------------- /src/views/timetable/event/AsyncContent.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 75 | {{ event?.name }} 76 | 77 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 97 | 98 | 99 | 100 | 109 | 110 | 111 | 112 | 113 | 133 | -------------------------------------------------------------------------------- /.github/workflows/main_commit.yml: -------------------------------------------------------------------------------- 1 | name: Тесты и раскатка в тестовую среду 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | test: 13 | name: Проверяем стили 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - uses: pnpm/action-setup@v4 20 | with: 21 | version: 9 22 | 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 22 26 | cache: "pnpm" 27 | 28 | - name: Install 29 | run: pnpm install 30 | 31 | - name: Check 32 | run: pnpm run check 33 | 34 | test-format: 35 | name: Выполняем тесты 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v4 40 | 41 | - uses: pnpm/action-setup@v4 42 | with: 43 | version: 9 44 | 45 | - uses: actions/setup-node@v4 46 | with: 47 | node-version: 22 48 | cache: "pnpm" 49 | 50 | - name: Install 51 | run: pnpm install 52 | 53 | - name: Check 54 | run: pnpm test 55 | 56 | build-test-image: 57 | name: Собираем тестовый Docker 58 | runs-on: ubuntu-latest 59 | needs: 60 | - test 61 | - test-format 62 | permissions: 63 | contents: read 64 | packages: write 65 | 66 | steps: 67 | - name: Checkout repository 68 | uses: actions/checkout@v4 69 | 70 | - name: Log in to the Container registry 71 | uses: docker/login-action@v3 72 | with: 73 | registry: ${{ env.REGISTRY }} 74 | username: ${{ github.actor }} 75 | password: ${{ secrets.GITHUB_TOKEN }} 76 | 77 | - name: Extract metadata (tags, labels) for Docker 78 | id: meta 79 | uses: docker/metadata-action@v5 80 | with: 81 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 82 | tags: | 83 | type=raw,value=test,enable=true 84 | 85 | - name: Build and push Docker image 86 | uses: docker/build-push-action@v5 87 | with: 88 | file: ./deployment/Dockerfile 89 | context: . 90 | push: true 91 | build-args: | 92 | BUILD_MODE=testing 93 | LAUNCH_MODE=testing 94 | APP_VERSION=${{ github.ref_name }} 95 | tags: ${{ steps.meta.outputs.tags }} 96 | labels: ${{ steps.meta.outputs.labels }} 97 | 98 | deploy-test: 99 | name: Раскатываем тестовый Docker 100 | runs-on: [ self-hosted, Linux, testing ] 101 | needs: build-test-image 102 | environment: 103 | name: Testing 104 | url: https://app.test.profcomff.com/ 105 | env: 106 | CONTAINER_NAME: com_profcomff_app_test 107 | permissions: 108 | packages: read 109 | steps: 110 | - name: Run docker container 111 | run: | 112 | docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test 113 | docker stop ${{ env.CONTAINER_NAME }} || true && docker rm ${{ env.CONTAINER_NAME }} || true 114 | docker run \ 115 | --detach \ 116 | --restart on-failure:3 \ 117 | --network=web \ 118 | --name ${{ env.CONTAINER_NAME }} \ 119 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test 120 | -------------------------------------------------------------------------------- /src/assets/network_error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/views/admin/achievement/AchievementRecieversView.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | {{ achievement.name }} 70 | {{ achievement.description }} 71 | 72 | 73 | 74 | 75 | 76 | 77 | Выдать 78 | 79 | 80 | 81 | 82 | 83 | 84 | id 85 | Пользователь 86 | 87 | 88 | 89 | 90 | 91 | {{ user_id }} 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | Access restricted 103 | 104 | 105 | 106 | 107 | 108 | 123 | -------------------------------------------------------------------------------- /src/components/EmailPasswordForm.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 | 50 | 60 | 70 | 71 | 72 | Не помню пароль 73 | 74 | 75 | 85 | 86 | 87 | Пароли должны совпадать 88 | {{ buttonText }} 89 | 90 | 91 | 92 | 140 | -------------------------------------------------------------------------------- /src/views/admin/group/AdminGroupView.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 77 | 78 | 79 | {{ group?.name }} 80 | 86 | 87 | 88 | 93 | 94 | 95 | 96 | id 97 | 98 | 99 | 100 | 101 | {{ name }} 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 130 | -------------------------------------------------------------------------------- /src/views/admin/groups/GroupTreeNode.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 73 | 74 | 75 | 76 | {{ node.name }} 77 | 78 | 79 | 85 | 86 | 87 | 88 | 89 | 93 | Переименовать 94 | 95 | 99 | Удалить 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 132 | -------------------------------------------------------------------------------- /src/views/profile/sessions/AsyncContent.vue: -------------------------------------------------------------------------------- 1 | 92 | 93 | 94 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | {{ formatDevice(session_name) }} 106 | 107 | 108 | {{ formatTime(last_activity) }} 109 | 110 | 111 | 112 | 113 | 114 | 119 | 120 | 121 | 122 | 135 | -------------------------------------------------------------------------------- /src/api/controllers/TimetableApi.ts: -------------------------------------------------------------------------------- 1 | import { stringifyDate, getDateWithDayOffset } from './../../utils/date'; 2 | import { useTimetableStore } from './../../store/timetable'; 3 | import { apiClient } from '../client'; 4 | 5 | interface GetLecturersParams { 6 | query?: string; 7 | limit?: number; 8 | offset?: number; 9 | } 10 | 11 | interface GetEventsParams { 12 | limit?: number; 13 | offset?: number; 14 | start?: string; // format: 2023-12-30 15 | end?: string; // format: 2023-12-31 16 | group_id?: number; 17 | lecturer_id?: number; 18 | room_id?: number; 19 | detail?: Array<'comment' | 'description'>; 20 | format?: 'json' | 'ics'; 21 | } 22 | 23 | function getLecturer(id: number) { 24 | return apiClient.GET('/timetable/lecturer/{id}', { 25 | params: { path: { id } }, 26 | }); 27 | } 28 | 29 | function getLecturers(params?: GetLecturersParams) { 30 | return apiClient.GET('/timetable/lecturer/', { 31 | params: { query: params }, 32 | }); 33 | } 34 | 35 | export class TimetableApi { 36 | public static async getLecturer(id: number) { 37 | const { setLecturers } = useTimetableStore(); 38 | const { data } = await getLecturer(id); 39 | if (data) { 40 | setLecturers([data]); 41 | } 42 | } 43 | 44 | public static async getLecturers() { 45 | const { setLecturers } = useTimetableStore(); 46 | const { data } = await getLecturers(); 47 | if (data) { 48 | setLecturers(data.items); 49 | } 50 | } 51 | 52 | public static async getRoom(id: number) { 53 | const { setRooms } = useTimetableStore(); 54 | const { data } = await apiClient.GET('/timetable/room/{id}', { 55 | params: { path: { id } }, 56 | }); 57 | if (data) { 58 | setRooms([data]); 59 | } 60 | } 61 | 62 | public static async getRooms() { 63 | const { setRooms } = useTimetableStore(); 64 | const { data } = await apiClient.GET('/timetable/room/'); 65 | if (data) { 66 | setRooms(data.items); 67 | } 68 | } 69 | 70 | public static async getEvent(id: number) { 71 | const { setEvents } = useTimetableStore(); 72 | const { data } = await apiClient.GET('/timetable/event/{id}', { 73 | params: { path: { id } }, 74 | }); 75 | if (data) { 76 | setEvents([data]); 77 | } 78 | } 79 | 80 | public static async getEvents(params?: GetEventsParams) { 81 | const { setEvents } = useTimetableStore(); 82 | const { data } = await apiClient.GET('/timetable/event/', { 83 | params: { query: params }, 84 | }); 85 | if (data && data !== null) { 86 | setEvents(data.items); 87 | } 88 | } 89 | 90 | public static async getDayEvents(date: Date, groupId: number) { 91 | const { setDay } = useTimetableStore(); 92 | const { data } = await apiClient.GET('/timetable/event/', { 93 | params: { 94 | query: { 95 | start: stringifyDate(date), 96 | end: stringifyDate(getDateWithDayOffset(date, 1)), 97 | group_id: groupId, 98 | }, 99 | }, 100 | }); 101 | if (data && data !== null) { 102 | setDay(date, data.items); 103 | } 104 | } 105 | 106 | public static async getLecturerEvents(lecturerId: number) { 107 | const { setLecturerEvents, setLecturers } = useTimetableStore(); 108 | const { data } = await apiClient.GET('/timetable/event/', { 109 | params: { 110 | query: { 111 | start: stringifyDate(new Date()), 112 | end: stringifyDate(getDateWithDayOffset(new Date(), 1)), 113 | lecturer_id: lecturerId, 114 | }, 115 | }, 116 | }); 117 | if (data?.items.length) { 118 | setLecturers(data.items[0].lecturer); 119 | setLecturerEvents(lecturerId, data.items); 120 | } 121 | } 122 | 123 | public static async getRoomEvents(roomId: number) { 124 | const { setRoomEvents, setRooms } = useTimetableStore(); 125 | const { data } = await apiClient.GET('/timetable/event/', { 126 | params: { 127 | query: { 128 | start: stringifyDate(new Date()), 129 | end: stringifyDate(getDateWithDayOffset(new Date(), 1)), 130 | room_id: roomId, 131 | }, 132 | }, 133 | }); 134 | if (data?.items.length) { 135 | setRooms(data.items[0].room); 136 | setRoomEvents(roomId, data.items); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/router/auth.ts: -------------------------------------------------------------------------------- 1 | import { LocalStorage, LocalStorageItem } from '@/models/LocalStorage'; 2 | import { NavigationGuard, RouteRecordRaw } from 'vue-router'; 3 | import { useProfileStore } from '@/store/profile'; 4 | import { useToastStore } from '@/store/toast'; 5 | import { AuthApi } from '@/api'; 6 | import { UNKNOWN_DEVICE, LoginError } from '@/models'; 7 | 8 | import { apiClient } from '@/api/'; 9 | import { isAuthMethod } from '@/utils/authMethodName'; 10 | 11 | export const authRoutes: RouteRecordRaw[] = [ 12 | { 13 | path: '', 14 | component: () => import('@/views/auth/AuthView.vue'), 15 | }, 16 | { 17 | path: 'reset/password', 18 | component: () => import('@/views/auth/ResetPassword.vue'), 19 | }, 20 | { 21 | path: 'reset/email', 22 | component: () => import('@/views/auth/ResetEmail.vue'), 23 | }, 24 | { 25 | path: 'register-oauth', 26 | component: () => import('@/views/auth/OauthRegisterView.vue'), 27 | }, 28 | { 29 | path: 'error', 30 | component: () => import('@/views/auth/LoginErrorView.vue'), 31 | }, 32 | { 33 | path: 'oauth-authorized/:method([a-z\\-]+)', 34 | component: () => 'Hello world!', 35 | name: 'oauth-login', 36 | }, 37 | { 38 | path: 'register/success', 39 | component: () => 'Hello world!', 40 | }, 41 | ]; 42 | 43 | export const authHandler: NavigationGuard = async to => { 44 | const profileStore = useProfileStore(); 45 | const toastStore = useToastStore(); 46 | 47 | if (to.path.startsWith('/auth/oauth-authorized')) { 48 | const methodLink = to.params.method; 49 | if (!isAuthMethod(methodLink)) { 50 | return { 51 | path: '/auth/error', 52 | query: { text: 'Метод авторизации не существует', from: 'oauth' }, 53 | replace: true, 54 | }; 55 | } 56 | 57 | if (to.hash === '' && Object.keys(to.query).length === 0) { 58 | return { 59 | path: '/auth/error', 60 | query: { text: 'Отсутствуют параметры входа', from: 'oauth' }, 61 | replace: true, 62 | }; 63 | } 64 | 65 | const { response, data, error } = profileStore.isUserLogged 66 | ? await apiClient.POST(`/auth/${methodLink}/registration`, { 67 | body: { 68 | ...to.query, 69 | session_name: navigator.userAgent ?? UNKNOWN_DEVICE, 70 | }, 71 | }) 72 | : await apiClient.POST(`/auth/${methodLink}/login`, { 73 | body: { 74 | ...to.query, 75 | session_name: navigator.userAgent ?? UNKNOWN_DEVICE, 76 | }, 77 | }); 78 | 79 | if (response.ok && data?.token) { 80 | LocalStorage.set(LocalStorageItem.Token, data.token); 81 | profileStore.updateToken(); 82 | toastStore.push({ title: 'Вы успешно вошли в аккаунт' }); 83 | return { path: '/profile', replace: true }; 84 | } else if (error) { 85 | if (response.status === 401) { 86 | const loginError = error as LoginError; 87 | const id_token = loginError.id_token; 88 | 89 | if (typeof id_token !== 'string') { 90 | return { 91 | path: '/auth/error', 92 | query: { text: 'Переданы неверные данные для входа', from: 'oauth' }, 93 | replace: true, 94 | }; 95 | } 96 | 97 | sessionStorage.setItem('id-token', id_token); 98 | sessionStorage.setItem('id-token-issuer', methodLink); 99 | return { path: '/auth/register-oauth', replace: true }; 100 | } 101 | 102 | if (response.status === 422) { 103 | return { 104 | path: '/auth/error', 105 | query: { text: 'Выбран неверный аккаунт', from: 'oauth' }, 106 | replace: true, 107 | }; 108 | } 109 | 110 | if (response.status === 409) { 111 | return { 112 | path: '/auth/error', 113 | query: { text: 'Аккаунт с такими данными уже существуют', from: 'oauth' }, 114 | replace: true, 115 | }; 116 | } 117 | } 118 | 119 | return { 120 | path: '/auth/error', 121 | query: { text: 'Непредвиденная ошибка', from: 'oauth' }, 122 | replace: true, 123 | }; 124 | } 125 | 126 | if (to.path === '/auth/register/success') { 127 | const token = to.query.token as string; 128 | 129 | if (token) { 130 | await AuthApi.approveEmail(token); 131 | } 132 | return { path: '/auth', replace: true }; 133 | } 134 | }; 135 | -------------------------------------------------------------------------------- /src/views/auth/AuthView.vue: -------------------------------------------------------------------------------- 1 | 2 | 43 | 44 | 45 | 46 | 51 | 56 | 61 | 62 | 70 | 79 | Другие сервисы 80 | 81 | 82 | 83 | 84 | Еще нет профиля? 85 | Зарегистрируйтесь 86 | 87 | 88 | Уже есть профиль? 89 | Перейти к входу 90 | 91 | 92 | При регистрации и входе вы соглашаетесь 93 | 94 | с политикой обработки данных 95 | 96 | 97 | 98 | 99 | 100 | 101 | 159 | --------------------------------------------------------------------------------
Произошла ошибка при входе в аккаунт
{{ route.query.text }}
{{ props.name }}
{{ props.description }}
{{ props.toast.description }}
Нажимая эту кнопку вы подтверждаете смену Email адреса
У тебя еще нет достижений в приложении :(
21 | {{ title }} 22 |
{{ info }}
Наше приложение позволит получить доступ к сервисам для студентов ФФ МГУ!
Для просмотра расписания выберите свою группу:
У вас еще нет аккаунта на этот логин или метод входа.
52 | Если у вас уже есть аккаунт Твой ФФ – нажмите Нет и выберите другой метод входа. 53 |
55 | Если вы впервые входите в Твой ФФ – нажмите Да. Тогда мы создадим для вас новый 56 | профиль! 57 |
Создать новый профиль?
50 | Сегодня, {{ new Date().toLocaleDateString('ru-RU', { day: '2-digit', month: 'long' }) }} 51 |
{{ achievement.description }}