feat: добавлены анимации загрузки и переходов между разделами
- Добавлен полноэкранный индикатор загрузки с размытием фона (LoadingOverlay) - Реализован глобальный контекст загрузки для отслеживания переходов между страницами - Добавлены плавные fade-анимации при переходах между страницами - Реализованы поочередные (stagger) анимации для карточек: * Карточки дней на странице расписания * Карточки уроков внутри каждого дня * Карточки групп на главной странице - Анимации работают на всех устройствах, включая мобильные - Улучшен компонент Spinner с поддержкой разных размеров - Исправлена ошибка гидратации с вложенными <li> элементами в навигации - Оптимизированы задержки анимаций для более быстрого отображения контента
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<div className="page-transition-wrapper">
|
||||
<Component {...pageProps} />
|
||||
</div>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App(props: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -15,7 +31,9 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
<LoadingContextProvider>
|
||||
<AppContent {...props} />
|
||||
</LoadingContextProvider>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -30,6 +30,17 @@ export default function HomePage({ groups, groupsByCourse }: HomePageProps) {
|
||||
const [openCourses, setOpenCourses] = React.useState<Set<number>>(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) {
|
||||
</Head>
|
||||
<div className="min-h-screen p-4 md:p-8">
|
||||
<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>
|
||||
<p className="text-muted-foreground">Выберите группу для просмотра расписания</p>
|
||||
</div>
|
||||
|
||||
<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 isOpen = openCourses.has(course)
|
||||
const courseOffset = courseOffsets.offsets[course]
|
||||
|
||||
if (courseGroups.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={course}>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleCourse(course)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">
|
||||
{course} курс
|
||||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-5 w-5 transition-transform duration-200",
|
||||
isOpen && "transform rotate-180"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isOpen && (
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{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
|
||||
key={course}
|
||||
className="stagger-card"
|
||||
style={{
|
||||
animationDelay: `${0.1 + courseIndex * 0.05}s`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleCourse(course)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">
|
||||
{course} курс
|
||||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-5 w-5 transition-transform duration-200",
|
||||
isOpen && "transform rotate-180"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</CardHeader>
|
||||
{isOpen && (
|
||||
<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>
|
||||
@@ -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">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setAddGroupDialogOpen(true)}
|
||||
className="gap-2"
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.05}s` } as React.CSSProperties}
|
||||
>
|
||||
<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 />
|
||||
<span className="absolute -bottom-5 left-1/2 transform -translate-x-1/2 text-xs text-muted-foreground whitespace-nowrap sm:hidden">
|
||||
Тема
|
||||
</span>
|
||||
</div>
|
||||
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<FaGithub className="h-4 w-4" />
|
||||
GitHub
|
||||
</Button>
|
||||
</Link>
|
||||
<div
|
||||
className="stagger-card"
|
||||
style={{ animationDelay: `${0.15 + courseOffsets.totalGroups * 0.04 + 0.11}s` } as React.CSSProperties}
|
||||
>
|
||||
<Link href={GITHUB_REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<FaGithub className="h-4 w-4" />
|
||||
GitHub
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
46
src/shared/context/loading-context.tsx
Normal file
46
src/shared/context/loading-context.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/shared/ui/loading-overlay.tsx
Normal file
32
src/shared/ui/loading-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.spinner} />
|
||||
<div
|
||||
className={cn(
|
||||
styles.spinner,
|
||||
size === 'large' && styles.spinnerLarge,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -116,7 +116,7 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
|
||||
)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<>
|
||||
{isLoading && isLoading === url ? (
|
||||
button
|
||||
) : (
|
||||
@@ -124,6 +124,6 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
|
||||
{button}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }: {
|
||||
<div className='snap-start hidden md:block' style={{ flex: '0 0 3rem' }} />
|
||||
{day.lessons.map((lesson, i) => (
|
||||
<Lesson
|
||||
key={i}
|
||||
width={longNames ? 450 : 350}
|
||||
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)` }} />
|
||||
|
||||
@@ -50,7 +50,15 @@ export function Schedule({ days }: {
|
||||
return (
|
||||
<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) => (
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<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>}
|
||||
<CardHeader>
|
||||
<div className='flex gap-2 md:gap-4'>
|
||||
|
||||
Reference in New Issue
Block a user