feat: добавлены анимации загрузки и переходов между разделами

- Добавлен полноэкранный индикатор загрузки с размытием фона (LoadingOverlay)
- Реализован глобальный контекст загрузки для отслеживания переходов между страницами
- Добавлены плавные fade-анимации при переходах между страницами
- Реализованы поочередные (stagger) анимации для карточек:
  * Карточки дней на странице расписания
  * Карточки уроков внутри каждого дня
  * Карточки групп на главной странице
- Анимации работают на всех устройствах, включая мобильные
- Улучшен компонент Spinner с поддержкой разных размеров
- Исправлена ошибка гидратации с вложенными <li> элементами в навигации
- Оптимизированы задержки анимаций для более быстрого отображения контента
This commit is contained in:
kilyabin
2025-11-23 01:29:09 +04:00
parent e5262f8203
commit cf0137a8d6
11 changed files with 276 additions and 60 deletions

View File

@@ -1,9 +1,25 @@
import '@/shared/styles/globals.css' import '@/shared/styles/globals.css'
import type { AppProps } from 'next/app' import type { AppProps } from 'next/app'
import { ThemeProvider } from '@/shared/providers/theme-provider' 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 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 (
<>
<div className="page-transition-wrapper">
<Component {...pageProps} />
</div>
<LoadingOverlay isLoading={isLoading} />
</>
)
}
export default function App(props: AppProps) {
return ( return (
<> <>
<Head> <Head>
@@ -15,7 +31,9 @@ export default function App({ Component, pageProps }: AppProps) {
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<Component {...pageProps} /> <LoadingContextProvider>
<AppContent {...props} />
</LoadingContextProvider>
</ThemeProvider> </ThemeProvider>
</> </>
) )

View File

@@ -30,6 +30,17 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set([1])) const [openCourses, setOpenCourses] = React.useState<Set<number>>(new Set([1]))
const [addGroupDialogOpen, setAddGroupDialogOpen] = React.useState(false) 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) => { const toggleCourse = (course: number) => {
setOpenCourses(prev => { setOpenCourses(prev => {
const next = new Set(prev) const next = new Set(prev)
@@ -50,55 +61,80 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
</Head> </Head>
<div className="min-h-screen p-4 md:p-8"> <div className="min-h-screen p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-4"> <div className="max-w-4xl mx-auto space-y-4">
<div className="text-center space-y-2 mb-8"> <div className="text-center space-y-2 mb-8 stagger-card" style={{ animationDelay: '0.05s' } as React.CSSProperties}>
<h1 className="text-3xl md:text-4xl font-bold">Расписание занятий</h1> <h1 className="text-3xl md:text-4xl font-bold">Расписание занятий</h1>
<p className="text-muted-foreground">Выберите группу для просмотра расписания</p> <p className="text-muted-foreground">Выберите группу для просмотра расписания</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3, 4, 5].map(course => { {[1, 2, 3, 4, 5].map((course, courseIndex) => {
const courseGroups = groupsByCourse[course] || [] const courseGroups = groupsByCourse[course] || []
const isOpen = openCourses.has(course) const isOpen = openCourses.has(course)
const courseOffset = courseOffsets.offsets[course]
if (courseGroups.length === 0) { if (courseGroups.length === 0) {
return null return null
} }
return ( return (
<Card key={course}> <div
<CardHeader key={course}
className="cursor-pointer" className="stagger-card"
onClick={() => toggleCourse(course)} style={{
> animationDelay: `${0.1 + courseIndex * 0.05}s`,
<div className="flex items-center justify-between"> } as React.CSSProperties}
<CardTitle className="text-xl"> >
{course} курс <Card>
</CardTitle> <CardHeader
<ChevronDown className="cursor-pointer"
className={cn( onClick={() => toggleCourse(course)}
"h-5 w-5 transition-transform duration-200", >
isOpen && "transform rotate-180" <div className="flex items-center justify-between">
)} <CardTitle className="text-xl">
/> {course} курс
</div> </CardTitle>
</CardHeader> <ChevronDown
{isOpen && ( className={cn(
<CardContent> "h-5 w-5 transition-transform duration-200",
<div className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-2"> isOpen && "transform rotate-180"
{courseGroups.map(({ id, name }) => ( )}
<Link key={id} href={`/${id}`}> />
<Button
variant="outline"
className="w-full justify-center h-auto py-3 px-2 sm:px-4 text-sm sm:text-base whitespace-nowrap"
>
{name}
</Button>
</Link>
))}
</div> </div>
</CardContent> </CardHeader>
)} {isOpen && (
</Card> <CardContent>
<div className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{courseGroups.map(({ id, name }, groupIndex) => {
// Последовательная анимация: каждый следующий элемент с задержкой
// courseOffset - это количество групп во всех предыдущих курсах
// groupIndex - это индекс в текущем курсе
// Итого: последовательный счетчик для всех групп подряд
const globalIndex = courseOffset + groupIndex
const delay = 0.15 + globalIndex * 0.04
return (
<div
key={id}
className="stagger-card"
style={{
animationDelay: `${delay}s`,
} as React.CSSProperties}
>
<Link href={`/${id}`}>
<Button
variant="outline"
className="w-full justify-center h-auto py-3 px-2 sm:px-4 text-sm sm:text-base whitespace-nowrap"
>
{name}
</Button>
</Link>
</div>
)
})}
</div>
</CardContent>
)}
</Card>
</div>
) )
})} })}
</div> </div>
@@ -112,26 +148,39 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
)} )}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8"> <div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-8">
<Button <div
variant="secondary" className="stagger-card"
onClick={() => setAddGroupDialogOpen(true)} style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
className="gap-2" >
<Button
variant="secondary"
onClick={() => setAddGroupDialogOpen(true)}
className="gap-2"
>
<MdAdd className="h-4 w-4" />
Добавить группу
</Button>
</div>
<div
className="relative stagger-card"
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.08}s` } as React.CSSProperties}
> >
<MdAdd className="h-4 w-4" />
Добавить группу
</Button>
<div className="relative">
<ThemeSwitcher /> <ThemeSwitcher />
<span className="absolute -bottom-5 left-1/2 transform -translate-x-1/2 text-xs text-muted-foreground whitespace-nowrap sm:hidden"> <span className="absolute -bottom-5 left-1/2 transform -translate-x-1/2 text-xs text-muted-foreground whitespace-nowrap sm:hidden">
Тема Тема
</span> </span>
</div> </div>
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer"> <div
<Button variant="outline" className="gap-2"> className="stagger-card"
<FaGithub className="h-4 w-4" /> style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.11}s` } as React.CSSProperties}
GitHub >
</Button> <Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
</Link> <Button variant="outline" className="gap-2">
<FaGithub className="h-4 w-4" />
GitHub
</Button>
</Link>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,46 @@
import React from 'react'
import { useRouter } from 'next/router'
interface LoadingContextType {
isLoading: boolean
}
export const LoadingContext = React.createContext<LoadingContextType>({
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 (
<LoadingContext.Provider value={{ isLoading }}>
{children}
</LoadingContext.Provider>
)
}

View File

@@ -147,3 +147,36 @@
} }
} }
} }
/* 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);
}
}
}

View File

@@ -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 (
<div
className={cn(
'fixed inset-0 z-50 flex items-center justify-center',
'bg-background/80 backdrop-blur-md',
'transition-opacity duration-300',
isLoading ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
aria-label="Загрузка"
role="status"
aria-hidden={!isLoading}
>
{isLoading && (
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16">
<Spinner size="large" />
</div>
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,19 @@
import styles from './styles.module.scss' 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 ( return (
<div className={styles.spinner} /> <div
className={cn(
styles.spinner,
size === 'large' && styles.spinnerLarge,
className
)}
/>
) )
} }

View File

@@ -9,6 +9,16 @@
margin-right: 8px; 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 { @keyframes spin {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }

View File

@@ -116,7 +116,7 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
) )
return ( return (
<li> <>
{isLoading && isLoading === url ? ( {isLoading && isLoading === url ? (
button button
) : ( ) : (
@@ -124,6 +124,6 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
{button} {button}
</Link> </Link>
)} )}
</li> </>
) )
} }

View File

@@ -1,3 +1,4 @@
import React from 'react'
import type { Day as DayType } from '@/shared/model/day' import type { Day as DayType } from '@/shared/model/day'
import { getDayOfWeek } from '@/shared/utils' import { getDayOfWeek } from '@/shared/utils'
import { Lesson } from '@/widgets/schedule/lesson' import { Lesson } from '@/widgets/schedule/lesson'
@@ -48,9 +49,10 @@ export function Day({ day }: {
<div className='snap-start hidden md:block' style={{ flex: '0 0 3rem' }} /> <div className='snap-start hidden md:block' style={{ flex: '0 0 3rem' }} />
{day.lessons.map((lesson, i) => ( {day.lessons.map((lesson, i) => (
<Lesson <Lesson
key={i}
width={longNames ? 450 : 350} width={longNames ? 450 : 350}
lesson={lesson} lesson={lesson}
key={i} animationDelay={i * 0.08}
/> />
))} ))}
<div className='snap-start hidden md:block' style={{ flex: `0 0 calc(100vw - 4rem - ${longNames ? 450 : 350}px - 1rem)` }} /> <div className='snap-start hidden md:block' style={{ flex: `0 0 calc(100vw - 4rem - ${longNames ? 450 : 350}px - 1rem)` }} />

View File

@@ -50,7 +50,15 @@ export function Schedule({ days }: {
return ( return (
<div className="flex flex-col p-4 md:p-8 lg:p-16 gap-6 md:gap-12 lg:gap-14"> <div className="flex flex-col p-4 md:p-8 lg:p-16 gap-6 md:gap-12 lg:gap-14">
{days.map((day, i) => ( {days.map((day, i) => (
<Day day={day} key={`${group}_day${i}`} /> <div
key={`${group}_day${i}`}
className="stagger-card"
style={{
animationDelay: `${i * 0.1}s`,
} as React.CSSProperties}
>
<Day day={day} />
</div>
))} ))}
</div> </div>
) )

View File

@@ -22,9 +22,10 @@ import { BsFillGeoAltFill } from 'react-icons/bs'
import { RiGroup2Fill } from 'react-icons/ri' import { RiGroup2Fill } from 'react-icons/ri'
import { ResourcesDialog } from '@/widgets/schedule/resources-dialog' import { ResourcesDialog } from '@/widgets/schedule/resources-dialog'
export function Lesson({ lesson, width = 350 }: { export function Lesson({ lesson, width = 350, animationDelay }: {
lesson: LessonType lesson: LessonType
width: number width: number
animationDelay?: number
}) { }) {
const [resourcesDialogOpened, setResourcesDialogOpened] = React.useState(false) const [resourcesDialogOpened, setResourcesDialogOpened] = React.useState(false)
@@ -63,7 +64,12 @@ export function Lesson({ lesson, width = 350 }: {
} }
return ( return (
<Card className={`w-full ${width === 450 ? 'md:w-[450px] md:min-w-[450px] md:max-w-[450px]' : 'md:w-[350px] md:min-w-[350px] md:max-w-[350px]'} flex flex-col relative overflow-hidden snap-start scroll-ml-16 shrink-0`}> <Card
className={`w-full ${width === 450 ? 'md:w-[450px] md:min-w-[450px] md:max-w-[450px]' : 'md:w-[350px] md:min-w-[350px] md:max-w-[350px]'} flex flex-col relative overflow-hidden snap-start scroll-ml-16 shrink-0 stagger-card`}
style={animationDelay !== undefined ? {
animationDelay: `${animationDelay}s`,
} as React.CSSProperties : undefined}
>
{lesson.isChange && <div className='absolute top-0 left-0 w-full h-full bg-gradient-to-br from-[#ffc60026] to-[#95620026] pointer-events-none'></div>} {lesson.isChange && <div className='absolute top-0 left-0 w-full h-full bg-gradient-to-br from-[#ffc60026] to-[#95620026] pointer-events-none'></div>}
<CardHeader> <CardHeader>
<div className='flex gap-2 md:gap-4'> <div className='flex gap-2 md:gap-4'>