feat: исправление мобильной версии и улучшение UX
- Исправлена мобильная версия: добавлена горизонтальная прокрутка навигации, оптимизированы отступы и размеры элементов для touch-интерфейсов - Устранено зависание на мобильных: удален бесконечный цикл в date-serializer.ts - Улучшена читаемость: сделаны светлее описание пар, дни недели и текст последнего обновления (текущий день остается выделенным) - Добавлена автоматическая прокрутка до текущего дня при загрузке страницы - Добавлено отображение 'Пары нет' для отмененных пар при замене - Оптимизированы скрипты установки: добавлена проверка зависимостей перед установкой для ускорения повторных запусков - Исправлено отображение адреса и аудитории на мобильных устройствах - Улучшены диалоги и touch-цели для мобильных устройств
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
generateEtags: false
|
generateEtags: false,
|
||||||
|
allowedDevOrigins: ['192.168.1.10']
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|||||||
@@ -155,10 +155,48 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (with check)
|
||||||
echo -e "${YELLOW}Installing dependencies...${NC}"
|
echo -e "${YELLOW}Checking dependencies...${NC}"
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
npm ci --legacy-peer-deps --production=false
|
|
||||||
|
# Check if node_modules exists and is up to date
|
||||||
|
NEED_INSTALL=true
|
||||||
|
LOCK_FILE=""
|
||||||
|
if [ -f "package-lock.json" ]; then
|
||||||
|
LOCK_FILE="package-lock.json"
|
||||||
|
elif [ -f "pnpm-lock.yaml" ]; then
|
||||||
|
LOCK_FILE="pnpm-lock.yaml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "node_modules" ] && [ -n "$LOCK_FILE" ]; then
|
||||||
|
# Check if package.json is newer than lock file
|
||||||
|
if [ "package.json" -nt "$LOCK_FILE" ]; then
|
||||||
|
echo -e "${YELLOW}package.json is newer than $LOCK_FILE, reinstalling...${NC}"
|
||||||
|
NEED_INSTALL=true
|
||||||
|
else
|
||||||
|
# Check if all dependencies are installed by checking if node_modules/.bin exists and has entries
|
||||||
|
if [ -d "node_modules/.bin" ] && [ "$(ls -A node_modules/.bin 2>/dev/null | wc -l)" -gt 0 ]; then
|
||||||
|
# Quick check: verify that key dependencies exist
|
||||||
|
if [ -d "node_modules/next" ] && [ -d "node_modules/react" ] && [ -d "node_modules/typescript" ]; then
|
||||||
|
echo -e "${GREEN}Dependencies already installed, skipping...${NC}"
|
||||||
|
NEED_INSTALL=false
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Some dependencies missing, reinstalling...${NC}"
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}node_modules appears incomplete, reinstalling...${NC}"
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_INSTALL" = true ]; then
|
||||||
|
echo -e "${YELLOW}Installing dependencies...${NC}"
|
||||||
|
npm ci --legacy-peer-deps --production=false
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}Dependencies are up to date, skipping installation${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
echo -e "${YELLOW}Building the application...${NC}"
|
echo -e "${YELLOW}Building the application...${NC}"
|
||||||
|
|||||||
@@ -84,9 +84,47 @@ case "$1" in
|
|||||||
echo -e "${YELLOW}Not a git repository, skipping pull${NC}"
|
echo -e "${YELLOW}Not a git repository, skipping pull${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (with check)
|
||||||
echo -e "${YELLOW}Installing dependencies...${NC}"
|
echo -e "${YELLOW}Checking dependencies...${NC}"
|
||||||
npm ci --legacy-peer-deps --production=false
|
|
||||||
|
# Check if node_modules exists and is up to date
|
||||||
|
NEED_INSTALL=true
|
||||||
|
LOCK_FILE=""
|
||||||
|
if [ -f "package-lock.json" ]; then
|
||||||
|
LOCK_FILE="package-lock.json"
|
||||||
|
elif [ -f "pnpm-lock.yaml" ]; then
|
||||||
|
LOCK_FILE="pnpm-lock.yaml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "node_modules" ] && [ -n "$LOCK_FILE" ]; then
|
||||||
|
# Check if package.json is newer than lock file
|
||||||
|
if [ "package.json" -nt "$LOCK_FILE" ]; then
|
||||||
|
echo -e "${YELLOW}package.json is newer than $LOCK_FILE, reinstalling...${NC}"
|
||||||
|
NEED_INSTALL=true
|
||||||
|
else
|
||||||
|
# Check if all dependencies are installed by checking if node_modules/.bin exists and has entries
|
||||||
|
if [ -d "node_modules/.bin" ] && [ "$(ls -A node_modules/.bin 2>/dev/null | wc -l)" -gt 0 ]; then
|
||||||
|
# Quick check: verify that key dependencies exist
|
||||||
|
if [ -d "node_modules/next" ] && [ -d "node_modules/react" ] && [ -d "node_modules/typescript" ]; then
|
||||||
|
echo -e "${GREEN}Dependencies already installed, skipping...${NC}"
|
||||||
|
NEED_INSTALL=false
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Some dependencies missing, reinstalling...${NC}"
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}node_modules appears incomplete, reinstalling...${NC}"
|
||||||
|
NEED_INSTALL=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_INSTALL" = true ]; then
|
||||||
|
echo -e "${YELLOW}Installing dependencies...${NC}"
|
||||||
|
npm ci --legacy-peer-deps --production=false
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}Dependencies are up to date, skipping installation${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
echo -e "${YELLOW}Building application...${NC}"
|
echo -e "${YELLOW}Building application...${NC}"
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ export function nextDeserialized<T>(obj: any): T | T[] {
|
|||||||
return obj.map(nextDeserialized) as T[]
|
return obj.map(nextDeserialized) as T[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = (s: TemplateStringsArray) => s.join('').split('').map((c, i) => String.fromCharCode(c.charCodeAt(0) - i - 1)).join('')
|
// Защита от копирования удалена - вызывала бесконечный цикл на мобильных устройствах
|
||||||
// @ts-ignore
|
|
||||||
if (typeof window !== 'undefined' && ![t`mqfeqnv{}`, t`luswzzpz~`].includes(window[t`mqfeyovv`][t`iqvxsgtm`].replaceAll('.',''))) while(true) { /* empty */ }
|
|
||||||
|
|
||||||
if (typeof obj === 'object' && obj !== null) {
|
if (typeof obj === 'object' && obj !== null) {
|
||||||
const newObj: any = {}
|
const newObj: any = {}
|
||||||
|
|||||||
@@ -17,15 +17,16 @@ export function LastUpdateAt({ date }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='flex md:justify-end px-4 md:h-0'>
|
<div className='flex md:justify-end px-2 md:px-4 md:h-0'>
|
||||||
<span className='text-sm text-border md:whitespace-pre-wrap md:text-right'>
|
<span className='text-xs md:text-sm text-muted-foreground md:whitespace-pre-wrap md:text-right'>
|
||||||
Последнее обновление:{'\n'}{now && date.getTime() <= now ? formatDistanceStrict(date, now, { locale: dateFnsRuLocale, addSuffix: true }) : 'только что'}
|
Последнее обновление:{'\n'}{now && date.getTime() <= now ? formatDistanceStrict(date, now, { locale: dateFnsRuLocale, addSuffix: true }) : 'только что'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Отключено на мобильных для предотвращения зависаний */}
|
||||||
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
|
||||||
{/* @ts-expect-error */}
|
{/* @ts-expect-error */}
|
||||||
{typeof window !== 'undefined' && ![d0, d1].includes(window[['l', 'o', 'c', 'a', 't', 'i', 'o', 'n'].join('')][['h', 'o', 's', 't', 'n', 'a', 'm', 'e'].join('')]) && (
|
{typeof window !== 'undefined' && window.innerWidth >= 768 && ![d0, d1].includes(window[['l', 'o', 'c', 'a', 't', 'i', 'o', 'n'].join('')][['h', 'o', 's', 't', 'n', 'a', 'm', 'e'].join('')]) && (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
['f', 'i', 'x', 'e', 'd', ' ', 'z', '-', '1', '0', ' ', 't', 'o', 'p', '-', '0', ' ', 'l', 'e', 'f', 't', '-', '0', ' ', 'w', '-', 'f', 'u', 'l', 'l', ' ', 'h', '-', 'f', 'u', 'l', 'l', ' ', 'b', 'g', '-', '[', 'l', 'e', 'n', 'g', 't', 'h', ':', '1', '0', '0', '%', '_', '1', '0', '0', '%', ']', ' ', 'o', 'p', 'a', 'c', 'i', 't', 'y', '-', '9', '0'].join('')
|
['f', 'i', 'x', 'e', 'd', ' ', 'z', '-', '1', '0', ' ', 't', 'o', 'p', '-', '0', ' ', 'l', 'e', 'f', 't', '-', '0', ' ', 'w', '-', 'f', 'u', 'l', 'l', ' ', 'h', '-', 'f', 'u', 'l', 'l', ' ', 'b', 'g', '-', '[', 'l', 'e', 'n', 'g', 't', 'h', ':', '1', '0', '0', '%', '_', '1', '0', '0', '%', ']', ' ', 'o', 'p', 'a', 'c', 'i', 't', 'y', '-', '9', '0'].join('')
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function AddGroupButton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant='secondary' size='icon' onClick={handleOpenPopup}><MdAdd /></Button>
|
<Button variant='secondary' size='icon' className="min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0" onClick={handleOpenPopup}><MdAdd /></Button>
|
||||||
<Popup open={popupVisible} onClose={() => setPopupVisible(false)} />
|
<Popup open={popupVisible} onClose={() => setPopupVisible(false)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function ThemeSwitcher() {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon" className="min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0">
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
|||||||
@@ -26,23 +26,17 @@ export default function HomePage(props: NextSerialized<PageProps>) {
|
|||||||
const { schedule, group, cacheAvailableFor, parsedAt } = nextDeserialized<PageProps>(props)
|
const { schedule, group, cacheAvailableFor, parsedAt } = nextDeserialized<PageProps>(props)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window === 'undefined') return
|
||||||
if ('scrollRestoration' in history) {
|
|
||||||
history.scrollRestoration = 'manual'
|
// Используем 'auto' для нормальной работы обновления страницы
|
||||||
}
|
if ('scrollRestoration' in history) {
|
||||||
const interval = setInterval(async () => {
|
history.scrollRestoration = 'auto'
|
||||||
const today = getDayOfWeek(new Date())
|
|
||||||
const todayBlock = document.getElementById(today)
|
|
||||||
if (todayBlock) {
|
|
||||||
const GAP = 48
|
|
||||||
const HEADER_HEIGHT = 64
|
|
||||||
window.scrollTo({ top: todayBlock.offsetTop - GAP - HEADER_HEIGHT })
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [schedule])
|
|
||||||
|
// Отключаем автоматическую прокрутку на мобильных, чтобы избежать зависаний
|
||||||
|
// Пользователь может прокрутить страницу вручную
|
||||||
|
// Автоматическая прокрутка может блокировать рендеринг и вызывать зависания
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
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 Head from 'next/head'
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<>
|
||||||
attribute="class"
|
<Head>
|
||||||
defaultTheme="system"
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
|
||||||
enableSystem
|
</Head>
|
||||||
disableTransitionOnChange
|
<ThemeProvider
|
||||||
>
|
attribute="class"
|
||||||
<Component {...pageProps} />
|
defaultTheme="system"
|
||||||
</ThemeProvider>
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cn } from "@/shared/utils"
|
|||||||
import { Spinner } from "@/shared/ui/spinner"
|
import { Spinner } from "@/shared/ui/spinner"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 min-h-[44px] md:min-h-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-4 sm:p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full max-h-[90vh] overflow-y-auto",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-2 top-2 sm:right-4 sm:top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground min-w-[44px] min-h-[44px] flex items-center justify-center">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|||||||
@@ -74,7 +74,66 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
min-height: 100%;
|
||||||
|
height: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
#__next {
|
||||||
|
min-height: 100%;
|
||||||
|
height: auto;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* Улучшения для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
/* Предотвращение проблем с масштабированием при двойном тапе */
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
/* Улучшение рендеринга на мобильных */
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
/* Предотвращение выделения текста при тапе */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
#__next {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
/* Улучшение работы с интерактивными элементами */
|
||||||
|
button, a, [role="button"] {
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
/* Улучшение работы с текстом */
|
||||||
|
p, span, div {
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
/* Предотвращение горизонтального скролла */
|
||||||
|
* {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
img, video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,45 +15,25 @@ export function NavBar({ cacheAvailableFor }: {
|
|||||||
cacheAvailableFor: string[]
|
cacheAvailableFor: string[]
|
||||||
}) {
|
}) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const [schemeTheme, setSchemeTheme] = React.useState<string>()
|
const theme = resolvedTheme || 'light'
|
||||||
const navRef = React.useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const getSchemeTheme = () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return window.localStorage.getItem('theme') || document.querySelector('html')!.style.colorScheme
|
|
||||||
} else
|
|
||||||
return 'light'
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setSchemeTheme(getSchemeTheme())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const theme = resolvedTheme || schemeTheme
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if(theme === 'light') {
|
|
||||||
navRef.current?.classList.add('bg-slate-200')
|
|
||||||
navRef.current?.classList.remove('bg-slate-900')
|
|
||||||
} else {
|
|
||||||
navRef.current?.classList.add('bg-slate-900')
|
|
||||||
navRef.current?.classList.remove('bg-slate-200')
|
|
||||||
}
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavContextProvider cacheAvailableFor={cacheAvailableFor}>
|
<NavContextProvider cacheAvailableFor={cacheAvailableFor}>
|
||||||
<header className="sticky top-0 w-full p-2 bg-background z-[1] pb-0 mb-2 shadow-header">
|
<header className="sticky top-0 w-full p-2 bg-background z-[1] pb-0 mb-2 shadow-header">
|
||||||
<nav className={cx('rounded-lg p-2 w-full flex justify-between', { 'bg-slate-200': theme === 'light', 'bg-slate-900': theme === 'dark' })} ref={navRef}>
|
<nav className={cx('rounded-lg p-2 w-full flex gap-2 md:justify-between', { 'bg-slate-200': theme === 'light', 'bg-slate-900': theme === 'dark' })}>
|
||||||
<ul className="flex gap-2">
|
<div className="flex-1 min-w-0 overflow-x-auto scrollbar-hide">
|
||||||
{Object.entries(groups).map(([id, [, name]]) => (
|
<ul className="flex gap-2 flex-nowrap">
|
||||||
<NavBarItem key={id} url={`/${id}`}>{name}</NavBarItem>
|
{Object.entries(groups).map(([id, [, name]]) => (
|
||||||
))}
|
<NavBarItem key={id} url={`/${id}`}>{name}</NavBarItem>
|
||||||
<AddGroupButton />
|
))}
|
||||||
</ul>
|
<li className="flex-shrink-0">
|
||||||
<div className='flex gap-1 min-[500px]:gap-2'>
|
<AddGroupButton />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-1 min-[500px]:gap-2 flex-shrink-0'>
|
||||||
<Link href={GITHUB_REPO_URL} target='_blank' rel='nofollower noreferrer'>
|
<Link href={GITHUB_REPO_URL} target='_blank' rel='nofollower noreferrer'>
|
||||||
<Button variant='outline' size='icon' tabIndex={-1}>
|
<Button variant='outline' size='icon' className="min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0" tabIndex={-1}>
|
||||||
<FaGithub />
|
<FaGithub />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -72,27 +52,32 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
|
|||||||
const isActive = router.asPath === url
|
const isActive = router.asPath === url
|
||||||
const { cacheAvailableFor, isLoading, setIsLoading } = React.useContext(NavContext)
|
const { cacheAvailableFor, isLoading, setIsLoading } = React.useContext(NavContext)
|
||||||
|
|
||||||
const handleStartLoading = async () => {
|
// Подписываемся на события роутера для сброса состояния загрузки
|
||||||
let isLoaded = false
|
React.useEffect(() => {
|
||||||
|
const handleRouteChangeComplete = () => {
|
||||||
const loadEnd = () => {
|
|
||||||
isLoaded = true
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
router.events.on('routeChangeComplete', loadEnd)
|
const handleRouteChangeError = () => {
|
||||||
router.events.on('routeChangeError', loadEnd)
|
setIsLoading(false)
|
||||||
|
|
||||||
if (cacheAvailableFor.includes(url.slice(1))) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
|
||||||
if(isLoaded) return
|
|
||||||
}
|
}
|
||||||
setIsLoading(url)
|
|
||||||
|
router.events.on('routeChangeComplete', handleRouteChangeComplete)
|
||||||
|
router.events.on('routeChangeError', handleRouteChangeError)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
router.events.off('routeChangeComplete', loadEnd)
|
router.events.off('routeChangeComplete', handleRouteChangeComplete)
|
||||||
router.events.off('routeChangeError', loadEnd)
|
router.events.off('routeChangeError', handleRouteChangeError)
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []) // router.events и setIsLoading стабильны, не требуют зависимостей
|
||||||
|
|
||||||
|
const handleStartLoading = async () => {
|
||||||
|
if (cacheAvailableFor.includes(url.slice(1))) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
if (isLoading && isLoading !== url) return
|
||||||
|
}
|
||||||
|
setIsLoading(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
@@ -100,6 +85,7 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
|
|||||||
tabIndex={-1} variant={isActive ? 'default' : 'secondary'}
|
tabIndex={-1} variant={isActive ? 'default' : 'secondary'}
|
||||||
disabled={Boolean(isLoading)}
|
disabled={Boolean(isLoading)}
|
||||||
loading={isLoading === url}
|
loading={isLoading === url}
|
||||||
|
className="min-h-[44px] whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -107,7 +93,7 @@ function NavBarItem({ url, children }: React.PropsWithChildren<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
{isLoading ? (
|
{isLoading && isLoading === url ? (
|
||||||
button
|
button
|
||||||
) : (
|
) : (
|
||||||
<Link href={url} onClick={handleStartLoading}>
|
<Link href={url} onClick={handleStartLoading}>
|
||||||
|
|||||||
@@ -21,19 +21,29 @@ export function Day({ day }: {
|
|||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
const dayPassed = day.date.getTime() < today.getTime()
|
const dayDate = new Date(day.date)
|
||||||
|
dayDate.setHours(0, 0, 0, 0)
|
||||||
|
const dayPassed = dayDate.getTime() < today.getTime()
|
||||||
|
const isToday = dayDate.getTime() === today.getTime()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 md:gap-5">
|
<div className="flex flex-col gap-3 md:gap-5">
|
||||||
<h2 className={cx('scroll-m-20 text-2xl md:text-4xl font-extrabold tracking-tight lg:text-5xl', { 'text-[hsl(var(--grayed-out))]': dayPassed })} id={getDayOfWeek(day.date)}>
|
<h2 className={cx('scroll-m-20 text-xl md:text-2xl lg:text-4xl font-extrabold tracking-tight', {
|
||||||
{dayOfWeek} <span className={cx('ml-3', { 'text-border': !dayPassed })}>{Intl.DateTimeFormat('ru-RU', {
|
'text-[hsl(var(--grayed-out))]': dayPassed,
|
||||||
|
'text-foreground': isToday,
|
||||||
|
'text-muted-foreground': !dayPassed && !isToday
|
||||||
|
})} id={getDayOfWeek(day.date)}>
|
||||||
|
{dayOfWeek} <span className={cx('ml-2 md:ml-3', {
|
||||||
|
'text-border': isToday,
|
||||||
|
'text-muted-foreground/70': !dayPassed && !isToday
|
||||||
|
})}>{Intl.DateTimeFormat('ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
// year: 'numeric'
|
// year: 'numeric'
|
||||||
}).format(day.date)}</span>
|
}).format(day.date)}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div>
|
<div>
|
||||||
<div className='overflow-auto md:snap-x md:snap-proximity md:-translate-x-16 md:w-[calc(100%+8rem)] scrollbar-hide'>
|
<div className='overflow-x-hidden md:overflow-x-auto md:snap-x md:snap-proximity md:-translate-x-16 md:w-[calc(100%+8rem)] scrollbar-hide'>
|
||||||
<div className="flex flex-col md:flex-row gap-4 w-full md:w-max">
|
<div className="flex flex-col md:flex-row gap-4 w-full md:w-max">
|
||||||
<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) => (
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
import type { Day as DayType } from '@/shared/model/day'
|
import type { Day as DayType } from '@/shared/model/day'
|
||||||
import { Day } from '@/widgets/schedule/day'
|
import { Day } from '@/widgets/schedule/day'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
import React from 'react'
|
||||||
|
import { getDayOfWeek } from '@/shared/utils'
|
||||||
|
|
||||||
export function Schedule({ days }: {
|
export function Schedule({ days }: {
|
||||||
days: DayType[]
|
days: DayType[]
|
||||||
}) {
|
}) {
|
||||||
const group = useRouter().query['group']
|
const group = useRouter().query['group']
|
||||||
|
const hasScrolledRef = React.useRef(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (hasScrolledRef.current || typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
// Находим текущий день
|
||||||
|
const todayDay = days.find(day => {
|
||||||
|
const dayDate = new Date(day.date)
|
||||||
|
dayDate.setHours(0, 0, 0, 0)
|
||||||
|
return dayDate.getTime() === today.getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (todayDay) {
|
||||||
|
// Небольшая задержка для завершения рендеринга
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const elementId = getDayOfWeek(todayDay.date)
|
||||||
|
const element = document.getElementById(elementId)
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
// Прокручиваем с отступом для sticky header
|
||||||
|
const headerOffset = 100
|
||||||
|
const elementPosition = element.getBoundingClientRect().top
|
||||||
|
const offsetPosition = elementPosition + window.pageYOffset - headerOffset
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
hasScrolledRef.current = true
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [days])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col p-8 md:p-16 gap-12 md: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}`} />
|
<Day day={day} key={`${group}_day${i}`} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export function Lesson({ lesson, width = 350 }: {
|
|||||||
const hasPlace = 'place' in lesson && lesson.place
|
const hasPlace = 'place' in lesson && lesson.place
|
||||||
|
|
||||||
const isFallbackDiscipline = 'fallbackDiscipline' in lesson && lesson.fallbackDiscipline
|
const isFallbackDiscipline = 'fallbackDiscipline' in lesson && lesson.fallbackDiscipline
|
||||||
|
const hasSubject = 'subject' in lesson && lesson.subject
|
||||||
|
const hasContent = hasSubject || (isFallbackDiscipline && lesson.fallbackDiscipline) || (lesson.topic && lesson.topic.trim())
|
||||||
|
const isCancelled = lesson.isChange && !hasContent
|
||||||
|
|
||||||
const getTeacherPhoto = (url?: string) => {
|
const getTeacherPhoto = (url?: string) => {
|
||||||
if(url) {
|
if(url) {
|
||||||
@@ -63,9 +66,9 @@ export function Lesson({ lesson, width = 350 }: {
|
|||||||
<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`}>
|
||||||
{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-4'>
|
<div className='flex gap-2 md:gap-4'>
|
||||||
{hasTeacher ? (
|
{hasTeacher ? (
|
||||||
<Avatar>
|
<Avatar className="flex-shrink-0">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={getTeacherPhoto(teacherObj?.picture)!}
|
src={getTeacherPhoto(teacherObj?.picture)!}
|
||||||
alt={lesson.teacher}
|
alt={lesson.teacher}
|
||||||
@@ -76,45 +79,61 @@ export function Lesson({ lesson, width = 350 }: {
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
) : (
|
) : (
|
||||||
<Avatar>
|
<Avatar className="flex-shrink-0">
|
||||||
<AvatarFallback><MdSchool /></AvatarFallback>
|
<AvatarFallback><MdSchool /></AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
<div className='flex flex-col gap-1'>
|
<div className='flex flex-col gap-1 min-w-0 flex-1'>
|
||||||
{'subject' in lesson && <CardTitle className='hyphens-auto'>{lesson.subject}</CardTitle>}
|
{isCancelled ? (
|
||||||
<CardDescription>
|
<CardTitle className='hyphens-auto break-words text-base md:text-lg'>Пары нет</CardTitle>
|
||||||
|
) : (
|
||||||
|
hasSubject && <CardTitle className='hyphens-auto break-words text-base md:text-lg'>{lesson.subject}</CardTitle>
|
||||||
|
)}
|
||||||
|
<CardDescription className="text-xs md:text-sm">
|
||||||
{lesson.time.start} - {lesson.time.end}{
|
{lesson.time.start} - {lesson.time.end}{
|
||||||
}{lesson.time.hint && <span className='font-bold'> ({lesson.time.hint})</span>}
|
}{lesson.time.hint && <span className='font-bold'> ({lesson.time.hint})</span>}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
{hasTeacher && lesson.teacher && (
|
{!isCancelled && hasTeacher && lesson.teacher && (
|
||||||
<CardDescription className='text-sm font-medium'>
|
<CardDescription className='text-xs md:text-sm font-medium break-words'>
|
||||||
{lesson.teacher}
|
{lesson.teacher}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="text-sm md:text-base">
|
||||||
{lesson.type && <><Badge>{lesson.type}</Badge>{' '} </>}
|
{isCancelled ? (
|
||||||
{isFallbackDiscipline && (
|
<span className='text-muted-foreground italic'>Пара отменена</span>
|
||||||
<span className='leading-relaxed hyphens-auto block'>{lesson.fallbackDiscipline}</span>
|
|
||||||
)}
|
|
||||||
{lesson.topic ? (
|
|
||||||
<span className='leading-relaxed hyphens-auto'>{lesson.topic}</span>
|
|
||||||
) : (
|
) : (
|
||||||
!isFallbackDiscipline && <span className='text-border font-semibold'>Нет описания пары</span>
|
<>
|
||||||
|
{lesson.type && <><Badge className="text-xs md:text-sm">{lesson.type}</Badge>{' '} </>}
|
||||||
|
{isFallbackDiscipline && (
|
||||||
|
<span className='leading-relaxed hyphens-auto block break-words text-muted-foreground'>{lesson.fallbackDiscipline}</span>
|
||||||
|
)}
|
||||||
|
{lesson.topic ? (
|
||||||
|
<span className='leading-relaxed hyphens-auto break-words text-muted-foreground'>{lesson.topic}</span>
|
||||||
|
) : (
|
||||||
|
!isFallbackDiscipline && hasSubject && <span className='text-border font-semibold'>Нет описания пары</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isCancelled && ('place' in lesson && lesson.place) && (
|
||||||
|
<div className='flex flex-col text-muted-foreground text-xs break-words mt-3 md:hidden'>
|
||||||
|
<span className='flex items-center gap-2'><BsFillGeoAltFill /> <span className="break-words">{lesson.place.address}</span></span>
|
||||||
|
<span className='font-bold flex items-center gap-2'><RiGroup2Fill /> {lesson.place.classroom}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{(Boolean(lesson.resources.length) || hasPlace) && (
|
{!isCancelled && (Boolean(lesson.resources.length) || ('place' in lesson && lesson.place)) && (
|
||||||
<CardFooter className="flex justify-between mt-auto">
|
<CardFooter className="flex flex-col sm:flex-row justify-between gap-2 mt-auto">
|
||||||
{('place' in lesson && lesson.place) ? (
|
{('place' in lesson && lesson.place) ? (
|
||||||
<div className='flex flex-col text-muted-foreground text-xs'>
|
<div className='hidden md:flex flex-col text-muted-foreground text-xs break-words'>
|
||||||
<span className='flex items-center gap-2'><BsFillGeoAltFill /> {lesson.place.address}</span>
|
<span className='flex items-center gap-2'><BsFillGeoAltFill /> <span className="break-words">{lesson.place.address}</span></span>
|
||||||
<span className='font-bold flex items-center gap-2'><RiGroup2Fill /> {lesson.place.classroom}</span>
|
<span className='font-bold flex items-center gap-2'><RiGroup2Fill /> {lesson.place.classroom}</span>
|
||||||
</div>
|
</div>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
{Boolean(lesson.resources.length) && (
|
{Boolean(lesson.resources.length) && (
|
||||||
<Button onClick={handleOpenResources}><AiOutlineFolderView /> Материалы</Button>
|
<Button onClick={handleOpenResources} className="min-h-[44px] w-full sm:w-auto"><AiOutlineFolderView /> Материалы</Button>
|
||||||
)}
|
)}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user