From cf0137a8d62e1c9397e415363219dab3ed15bcec Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Sun, 23 Nov 2025 01:29:09 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен полноэкранный индикатор загрузки с размытием фона (LoadingOverlay) - Реализован глобальный контекст загрузки для отслеживания переходов между страницами - Добавлены плавные fade-анимации при переходах между страницами - Реализованы поочередные (stagger) анимации для карточек: * Карточки дней на странице расписания * Карточки уроков внутри каждого дня * Карточки групп на главной странице - Анимации работают на всех устройствах, включая мобильные - Улучшен компонент Spinner с поддержкой разных размеров - Исправлена ошибка гидратации с вложенными
  • элементами в навигации - Оптимизированы задержки анимаций для более быстрого отображения контента --- src/pages/_app.tsx | 22 +++- src/pages/index.tsx | 147 ++++++++++++++++--------- src/shared/context/loading-context.tsx | 46 ++++++++ src/shared/styles/globals.css | 33 ++++++ src/shared/ui/loading-overlay.tsx | 32 ++++++ src/shared/ui/spinner.tsx | 16 ++- src/shared/ui/styles.module.scss | 10 ++ src/widgets/navbar/index.tsx | 4 +- src/widgets/schedule/day.tsx | 6 +- src/widgets/schedule/index.tsx | 10 +- src/widgets/schedule/lesson.tsx | 10 +- 11 files changed, 276 insertions(+), 60 deletions(-) create mode 100644 src/shared/context/loading-context.tsx create mode 100644 src/shared/ui/loading-overlay.tsx diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index e7609ee..19dbb64 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,9 +1,25 @@ import '@/shared/styles/globals.css' import type { AppProps } from 'next/app' import { ThemeProvider } from '@/shared/providers/theme-provider' +import { LoadingContextProvider, LoadingContext } from '@/shared/context/loading-context' +import { LoadingOverlay } from '@/shared/ui/loading-overlay' import Head from 'next/head' +import React from 'react' -export default function App({ Component, pageProps }: AppProps) { +function AppContent({ Component, pageProps }: AppProps) { + const { isLoading } = React.useContext(LoadingContext) + + return ( + <> +
    + +
    + + + ) +} + +export default function App(props: AppProps) { return ( <> @@ -15,7 +31,9 @@ export default function App({ Component, pageProps }: AppProps) { enableSystem disableTransitionOnChange > - + + + ) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index b9c2f64..233ca06 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -30,6 +30,17 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) { const [openCourses, setOpenCourses] = React.useState>(new Set([1])) const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false) + // Подсчитываем смещения для каждого курса для последовательной анимации + const courseOffsets = React.useMemo(() => { + const offsets: { [course: number]: number } = {} + let totalGroups = 0 + for (const course of [1, 2, 3, 4, 5]) { + offsets[course] = totalGroups + totalGroups += (groupsByCourse[course] || []).length + } + return { offsets, totalGroups } + }, [groupsByCourse]) + const toggleCourse = (course: number) => { setOpenCourses(prev => { const next = new Set(prev) @@ -50,55 +61,80 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
    -
    +

    Расписание занятий

    Выберите группу для просмотра расписания

    - {[1, 2, 3, 4, 5].map(course => { + {[1, 2, 3, 4, 5].map((course, courseIndex) => { const courseGroups = groupsByCourse[course] || [] const isOpen = openCourses.has(course) + const courseOffset = courseOffsets.offsets[course] if (courseGroups.length === 0) { return null } return ( - - toggleCourse(course)} - > -
    - - {course} курс - - -
    -
    - {isOpen && ( - -
    - {courseGroups.map(({ id, name }) => ( - - - - ))} +
    + + toggleCourse(course)} + > +
    + + {course} курс + +
    - - )} -
    + + {isOpen && ( + +
    + {courseGroups.map(({ id, name }, groupIndex) => { + // Последовательная анимация: каждый следующий элемент с задержкой + // courseOffset - это количество групп во всех предыдущих курсах + // groupIndex - это индекс в текущем курсе + // Итого: последовательный счетчик для всех групп подряд + const globalIndex = courseOffset + groupIndex + const delay = 0.15 + globalIndex * 0.04 + return ( +
    + + + +
    + ) + })} +
    +
    + )} + +
    ) })}
    @@ -112,26 +148,39 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) { )}
    - +
    +
    - - Добавить группу - -
    Тема
    - - - +
    + + + +
    diff --git a/src/shared/context/loading-context.tsx b/src/shared/context/loading-context.tsx new file mode 100644 index 0000000..217a3be --- /dev/null +++ b/src/shared/context/loading-context.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { useRouter } from 'next/router' + +interface LoadingContextType { + isLoading: boolean +} + +export const LoadingContext = React.createContext({ + isLoading: false, +}) + +export function LoadingContextProvider({ children }: React.PropsWithChildren) { + const [isLoading, setIsLoading] = React.useState(false) + const router = useRouter() + + React.useEffect(() => { + const handleRouteChangeStart = () => { + setIsLoading(true) + } + + const handleRouteChangeComplete = () => { + setIsLoading(false) + } + + const handleRouteChangeError = () => { + setIsLoading(false) + } + + router.events.on('routeChangeStart', handleRouteChangeStart) + router.events.on('routeChangeComplete', handleRouteChangeComplete) + router.events.on('routeChangeError', handleRouteChangeError) + + return () => { + router.events.off('routeChangeStart', handleRouteChangeStart) + router.events.off('routeChangeComplete', handleRouteChangeComplete) + router.events.off('routeChangeError', handleRouteChangeError) + } + }, [router]) + + return ( + + {children} + + ) +} + diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index 8a3c148..49576be 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -146,4 +146,37 @@ height: auto; } } +} + +/* Page transition animations */ +@layer utilities { + .page-transition-wrapper { + animation: fadeIn 0.3s ease-in-out; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + /* Stagger animation for cards - works on all devices */ + .stagger-card { + animation: fadeInSlideUp 0.3s ease-out forwards; + opacity: 0; + } + + @keyframes fadeInSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } } \ No newline at end of file diff --git a/src/shared/ui/loading-overlay.tsx b/src/shared/ui/loading-overlay.tsx new file mode 100644 index 0000000..97fc510 --- /dev/null +++ b/src/shared/ui/loading-overlay.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Spinner } from './spinner' +import { cn } from '@/shared/utils' + +interface LoadingOverlayProps { + isLoading: boolean +} + +export function LoadingOverlay({ isLoading }: LoadingOverlayProps) { + return ( +
    + {isLoading && ( +
    +
    + +
    +
    + )} +
    + ) +} + diff --git a/src/shared/ui/spinner.tsx b/src/shared/ui/spinner.tsx index cdb3f37..0096198 100644 --- a/src/shared/ui/spinner.tsx +++ b/src/shared/ui/spinner.tsx @@ -1,7 +1,19 @@ import styles from './styles.module.scss' +import { cn } from '@/shared/utils' -export function Spinner() { +interface SpinnerProps { + size?: 'small' | 'large' + className?: string +} + +export function Spinner({ size = 'small', className }: SpinnerProps) { return ( -
    +
    ) } \ No newline at end of file diff --git a/src/shared/ui/styles.module.scss b/src/shared/ui/styles.module.scss index 04917c2..6757d48 100644 --- a/src/shared/ui/styles.module.scss +++ b/src/shared/ui/styles.module.scss @@ -9,6 +9,16 @@ margin-right: 8px; } +.spinnerLarge { + width: 64px; + height: 64px; + border-width: 4px; + margin-right: 0; + border-color: hsl(var(--foreground) / 0.3); + border-left-color: hsl(var(--foreground)); + border-top-color: hsl(var(--foreground)); +} + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } diff --git a/src/widgets/navbar/index.tsx b/src/widgets/navbar/index.tsx index 20280e3..6a55665 100644 --- a/src/widgets/navbar/index.tsx +++ b/src/widgets/navbar/index.tsx @@ -116,7 +116,7 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{ ) return ( -
  • + <> {isLoading && isLoading === url ? ( button ) : ( @@ -124,6 +124,6 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{ {button} )} -
  • + ) } \ No newline at end of file diff --git a/src/widgets/schedule/day.tsx b/src/widgets/schedule/day.tsx index e0c7db0..7cbb9a2 100644 --- a/src/widgets/schedule/day.tsx +++ b/src/widgets/schedule/day.tsx @@ -1,3 +1,4 @@ +import React from 'react' import type { Day as DayType } from '@/shared/model/day' import { getDayOfWeek } from '@/shared/utils' import { Lesson } from '@/widgets/schedule/lesson' @@ -48,9 +49,10 @@ export function Day({ day }: {
    {day.lessons.map((lesson, i) => ( ))}
    diff --git a/src/widgets/schedule/index.tsx b/src/widgets/schedule/index.tsx index e633d37..58971de 100644 --- a/src/widgets/schedule/index.tsx +++ b/src/widgets/schedule/index.tsx @@ -50,7 +50,15 @@ export function Schedule({ days }: { return (
    {days.map((day, i) => ( - +
    + +
    ))}
    ) diff --git a/src/widgets/schedule/lesson.tsx b/src/widgets/schedule/lesson.tsx index 6aa4ded..5c72d1d 100644 --- a/src/widgets/schedule/lesson.tsx +++ b/src/widgets/schedule/lesson.tsx @@ -22,9 +22,10 @@ import { BsFillGeoAltFill } from 'react-icons/bs' import { RiGroup2Fill } from 'react-icons/ri' import { ResourcesDialog } from '@/widgets/schedule/resources-dialog' -export function Lesson({ lesson, width = 350 }: { +export function Lesson({ lesson, width = 350, animationDelay }: { lesson: LessonType width: number + animationDelay?: number }) { const [resourcesDialogOpened, setResourcesDialogOpened] = React.useState(false) @@ -63,7 +64,12 @@ export function Lesson({ lesson, width = 350 }: { } return ( - + {lesson.isChange &&
    }